From 4e4951c4097b18c674d5bc1b526b83c0145bf4b8 Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Tue, 18 Aug 2026 10:19:31 +0800 Subject: [PATCH 1/7] feat(runtimeprovider): dispatch class projects through verified providers Resolve runtime metadata from the effective module graph, build host providers with explicit environment and flag policies, and preserve process status across run, build, and install. Keep class modules ordered as resolved records and cover the CLI boundary end to end. --- cmd/internal/base/pass.go | 3 +- cmd/internal/build/build.go | 17 + cmd/internal/install/install.go | 17 + cmd/internal/run/run.go | 29 +- cmd/internal/runtimeprovider/cli_e2e_test.go | 233 ++++++ cmd/internal/runtimeprovider/command.go | 36 + cmd/internal/runtimeprovider/dispatch.go | 157 ++++ cmd/internal/runtimeprovider/dispatch_test.go | 102 +++ .../runtimeprovider/executable_darwin.go | 75 ++ .../runtimeprovider/executable_linux.go | 33 + .../runtimeprovider/executable_other.go | 23 + .../runtimeprovider/executable_windows.go | 33 + cmd/internal/runtimeprovider/flags.go | 220 ++++++ cmd/internal/runtimeprovider/flags_test.go | 95 +++ cmd/internal/runtimeprovider/graph.go | 552 +++++++++++++ cmd/internal/runtimeprovider/graph_test.go | 244 ++++++ cmd/internal/runtimeprovider/output.go | 306 +++++++ cmd/internal/runtimeprovider/output_test.go | 309 ++++++++ cmd/internal/runtimeprovider/process_unix.go | 160 ++++ .../runtimeprovider/process_unix_test.go | 245 ++++++ .../runtimeprovider/process_windows.go | 133 ++++ .../runtimeprovider/process_windows_test.go | 105 +++ cmd/internal/runtimeprovider/protocol.go | 151 ++++ cmd/internal/runtimeprovider/protocol_test.go | 137 ++++ cmd/internal/runtimeprovider/provider.go | 316 ++++++++ cmd/internal/runtimeprovider/provider_test.go | 55 ++ cmd/internal/runtimeprovider/resolve.go | 746 ++++++++++++++++++ cmd/internal/runtimeprovider/resolve_test.go | 704 +++++++++++++++++ .../runtimeprovider/signal_boundary_unix.go | 95 +++ .../signal_boundary_unix_test.go | 47 ++ .../signal_boundary_windows.go | 74 ++ cmd/internal/runtimeprovider/status.go | 28 + cmd/internal/runtimeprovider/status_unix.go | 39 + .../runtimeprovider/status_windows.go | 23 + cmd/internal/runtimeprovider/types.go | 124 +++ cmd/internal/runtimeprovider/version.go | 91 +++ go.mod | 7 +- 37 files changed, 5757 insertions(+), 7 deletions(-) create mode 100644 cmd/internal/runtimeprovider/cli_e2e_test.go create mode 100644 cmd/internal/runtimeprovider/command.go create mode 100644 cmd/internal/runtimeprovider/dispatch.go create mode 100644 cmd/internal/runtimeprovider/dispatch_test.go create mode 100644 cmd/internal/runtimeprovider/executable_darwin.go create mode 100644 cmd/internal/runtimeprovider/executable_linux.go create mode 100644 cmd/internal/runtimeprovider/executable_other.go create mode 100644 cmd/internal/runtimeprovider/executable_windows.go create mode 100644 cmd/internal/runtimeprovider/flags.go create mode 100644 cmd/internal/runtimeprovider/flags_test.go create mode 100644 cmd/internal/runtimeprovider/graph.go create mode 100644 cmd/internal/runtimeprovider/graph_test.go create mode 100644 cmd/internal/runtimeprovider/output.go create mode 100644 cmd/internal/runtimeprovider/output_test.go create mode 100644 cmd/internal/runtimeprovider/process_unix.go create mode 100644 cmd/internal/runtimeprovider/process_unix_test.go create mode 100644 cmd/internal/runtimeprovider/process_windows.go create mode 100644 cmd/internal/runtimeprovider/process_windows_test.go create mode 100644 cmd/internal/runtimeprovider/protocol.go create mode 100644 cmd/internal/runtimeprovider/protocol_test.go create mode 100644 cmd/internal/runtimeprovider/provider.go create mode 100644 cmd/internal/runtimeprovider/provider_test.go create mode 100644 cmd/internal/runtimeprovider/resolve.go create mode 100644 cmd/internal/runtimeprovider/resolve_test.go create mode 100644 cmd/internal/runtimeprovider/signal_boundary_unix.go create mode 100644 cmd/internal/runtimeprovider/signal_boundary_unix_test.go create mode 100644 cmd/internal/runtimeprovider/signal_boundary_windows.go create mode 100644 cmd/internal/runtimeprovider/status.go create mode 100644 cmd/internal/runtimeprovider/status_unix.go create mode 100644 cmd/internal/runtimeprovider/status_windows.go create mode 100644 cmd/internal/runtimeprovider/types.go create mode 100644 cmd/internal/runtimeprovider/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..6585143c6 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/runtimeprovider" "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) } + runtimeResult, runtimeErr := tryRuntime(proj, pass.Args, *flagOutput) + if runtimeErr != nil { + fmt.Fprintln(os.Stderr, runtimeErr) + os.Exit(1) + } + if runtimeResult.Handled { + if runtimeResult.Status.Signaled || runtimeResult.Status.Code != 0 { + runtimeprovider.Exit(runtimeResult.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 tryRuntime(proj xgoprojs.Proj, flags []string, output string) (runtimeprovider.DispatchResult, error) { + return runtimeprovider.TryBuild(context.Background(), "", proj, flags, output, runtimeprovider.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..7f5c58eb9 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/runtimeprovider" "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) } + runtimeResult, runtimeErr := tryRuntime(projs, pass.Args) + if runtimeErr != nil { + fmt.Fprintln(os.Stderr, runtimeErr) + os.Exit(1) + } + if runtimeResult.Handled { + if runtimeResult.Status.Signaled || runtimeResult.Status.Code != 0 { + runtimeprovider.Exit(runtimeResult.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 tryRuntime(projs []xgoprojs.Proj, flags []string) (runtimeprovider.DispatchResult, error) { + return runtimeprovider.TryInstall(context.Background(), "", projs, flags, runtimeprovider.Streams{}) +} + func install(proj xgoprojs.Proj, conf *tool.Config, install *gocmd.InstallConfig) { const flags = tool.GenFlagPrompt var obj string diff --git a/cmd/internal/run/run.go b/cmd/internal/run/run.go index 2d7ba52d8..4e19228e1 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/runtimeprovider" "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 + runtimeFlags := append([]string(nil), pass.Args...) + if *flagAsm { + runtimeFlags = append(runtimeFlags, "-asm=true") + } + if *flagNoChdir { + runtimeFlags = append(runtimeFlags, "-nc=true") + } + if *flagProf { + runtimeFlags = append(runtimeFlags, "-prof=true") + } + runtimeResult, runtimeErr := tryRuntime(proj, args, runtimeFlags) + if runtimeErr != nil { + fmt.Fprintln(os.Stderr, runtimeErr) + os.Exit(1) + } + if runtimeResult.Handled { + if runtimeResult.Status.Signaled || runtimeResult.Status.Code != 0 { + runtimeprovider.Exit(runtimeResult.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 tryRuntime(proj xgoprojs.Proj, args, flags []string) (runtimeprovider.DispatchResult, error) { + return runtimeprovider.TryRun(context.Background(), "", proj, flags, args, runtimeprovider.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/cmd/internal/runtimeprovider/cli_e2e_test.go b/cmd/internal/runtimeprovider/cli_e2e_test.go new file mode 100644 index 000000000..ba733f14c --- /dev/null +++ b/cmd/internal/runtimeprovider/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 runtimeprovider + +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 provider") + } + buildGoWork := os.Getenv("GOWORK") + modDir := protocolModuleDir(t, buildGoWork) + fixture := newRuntimeFixture(t) + configureSharedProtocolProvider(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_PROVIDER_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, "provider-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_PROVIDER_MARKER="+marker) + output, err = multi.CombinedOutput() + if err == nil || !strings.Contains(string(output), "exactly one target") { + t.Fatalf("multi runtime install = %v\n%s", err, output) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("provider 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 configureSharedProtocolProvider(t *testing.T, fixture runtimeFixture, modDir string) { + t.Helper() + mustWriteFile(t, filepath.Join(fixture.framework, "cmd", "provider", "main.go"), protocolFakeProviderSource) + 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 provider 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, "runtimeprotocol_tools.go")); !os.IsNotExist(err) { + t.Fatalf("fixture contains a test-only tools file: %v", err) + } +} + +const protocolFakeProviderSource = `package main + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + + "github.com/goplus/mod/runtimeprotocol" +) + +func main() { + if marker := os.Getenv("FAKE_PROVIDER_MARKER"); marker != "" { + _ = os.WriteFile(marker, []byte("started"), 0600) + } + if value := os.Getenv("FAKE_PROVIDER_EXIT"); value != "" { + code, _ := strconv.Atoi(value) + os.Exit(code) + } + request, err := runtimeprotocol.Parse(os.Args[1:]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(90) + } + if os.Getenv("FAKE_PROVIDER_EXPECT_NO_PACK") != "" && request.Project.Pack != nil { + os.Exit(94) + } + switch request.Action { + case runtimeprotocol.ActionRun: + fmt.Printf("run-args=%s\n", strings.Join(request.ApplicationArgs, "|")) + case runtimeprotocol.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("runtime path touched xgo_autogen.go: %v", err) + } +} diff --git a/cmd/internal/runtimeprovider/command.go b/cmd/internal/runtimeprovider/command.go new file mode 100644 index 000000000..74f1a4c69 --- /dev/null +++ b/cmd/internal/runtimeprovider/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 runtimeprovider + +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/runtimeprovider/dispatch.go b/cmd/internal/runtimeprovider/dispatch.go new file mode 100644 index 000000000..773894e72 --- /dev/null +++ b/cmd/internal/runtimeprovider/dispatch.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 runtimeprovider + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/goplus/xgo/x/xgoprojs" +) + +// DispatchResult reports whether a runtime target was selected and, if so, +// the provider process status and build output path. +type DispatchResult struct { + Handled bool + Status ProcessStatus + Output string +} + +// TryRun resolves and, when matched, runs one runtime 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 target == nil { + return DispatchResult{}, fmt.Errorf("runtime provider run requires a non-nil target") + } + resolver, ctx, err := newDispatchResolver(ctx, cwd, flags) + if err != nil { + return DispatchResult{}, err + } + rt, handled, err := resolveTarget(ctx, resolver, target) + if err != nil || !handled { + return DispatchResult{Handled: handled}, err + } + if len(appArgs) != 0 && appArgs[0] == "--" { + appArgs = appArgs[1:] + } + boundary := beginRuntimeSignalBoundary(ctx) + status, err := resolver.Run(boundary.Context(), rt, appArgs, streams) + status, err = boundary.Finish(status, err) + if err != nil { + return DispatchResult{}, err + } + return DispatchResult{Handled: true, Status: status}, nil +} + +// TryBuild resolves and, when matched, builds one runtime 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) { + if target == nil { + return DispatchResult{}, fmt.Errorf("runtime provider build requires a non-nil target") + } + resolver, ctx, err := newDispatchResolver(ctx, cwd, flags) + if err != nil { + return DispatchResult{}, err + } + rt, handled, err := resolveTarget(ctx, resolver, target) + if err != nil || !handled { + return DispatchResult{Handled: handled}, err + } + boundary := beginRuntimeSignalBoundary(ctx) + status, final, err := resolver.Build(boundary.Context(), rt, output, streams) + status, err = boundary.Finish(status, err) + if err != nil { + return DispatchResult{}, err + } + return DispatchResult{Handled: true, Status: status, Output: final}, nil +} + +// TryInstall resolves every target before starting a provider. A request with +// multiple targets is rejected only when at least one target is runtime-backed; +// an all-legacy request remains available to the existing install path. This +// keeps provider execution and output-directory creation after validation. +func TryInstall(ctx context.Context, cwd string, targets []xgoprojs.Proj, flags []string, streams Streams) (DispatchResult, error) { + if len(targets) == 0 { + return DispatchResult{}, fmt.Errorf("runtime provider install requires at least one target") + } + for _, target := range targets { + if target == nil { + return DispatchResult{}, fmt.Errorf("runtime provider install requires non-nil targets") + } + } + resolver, ctx, err := newDispatchResolver(ctx, cwd, flags) + if err != nil { + return DispatchResult{}, err + } + runtimes := make([]*Runtime, 0, len(targets)) + for _, target := range targets { + rt, handled, err := resolveTarget(ctx, resolver, target) + if err != nil { + return DispatchResult{}, err + } + if handled { + runtimes = append(runtimes, rt) + } + } + if len(runtimes) == 0 { + return DispatchResult{}, nil + } + if len(targets) != 1 { + return DispatchResult{}, fmt.Errorf("runtime provider v1 install accepts exactly one target") + } + boundary := beginRuntimeSignalBoundary(ctx) + status, final, err := resolver.Install(boundary.Context(), runtimes[0], streams) + status, err = boundary.Finish(status, err) + if err != nil { + return DispatchResult{}, err + } + return DispatchResult{Handled: true, Status: status, Output: final}, 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) (*Runtime, bool, error) { + if target == nil { + return nil, false, fmt.Errorf("runtime provider dispatch requires a non-nil target") + } + rt, err := resolver.Resolve(ctx, target) + if errors.Is(err, ErrNotHandled) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return rt, true, nil +} diff --git a/cmd/internal/runtimeprovider/dispatch_test.go b/cmd/internal/runtimeprovider/dispatch_test.go new file mode 100644 index 000000000..d97fbea13 --- /dev/null +++ b/cmd/internal/runtimeprovider/dispatch_test.go @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package runtimeprovider + +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 := newRuntimeFixture(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 TestDispatchRejectsMixedTargetsBeforeProvider(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(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, "provider-started") + t.Setenv("FAKE_PROVIDER_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("provider started before target validation: %v", err) + } +} + +func setFixtureRequiredXGo(t *testing.T, fixture runtimeFixture, 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) + } +} diff --git a/cmd/internal/runtimeprovider/executable_darwin.go b/cmd/internal/runtimeprovider/executable_darwin.go new file mode 100644 index 000000000..1a8b151df --- /dev/null +++ b/cmd/internal/runtimeprovider/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 runtimeprovider + +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("runtime provider 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 runtime provider 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("runtime provider output changed before signature validation") + } + cmd := exec.Command("/usr/bin/codesign", "--verify", "--strict", currentPath) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("runtime provider 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("runtime provider 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/runtimeprovider/executable_linux.go b/cmd/internal/runtimeprovider/executable_linux.go new file mode 100644 index 000000000..4b3cdddbc --- /dev/null +++ b/cmd/internal/runtimeprovider/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 runtimeprovider + +import ( + "debug/elf" + "fmt" + "os" +) + +func validateHostExecutable(input *os.File, _ string) error { + file, err := elf.NewFile(input) + if err != nil { + return fmt.Errorf("runtime provider output is not a Linux executable: %w", err) + } + return file.Close() +} diff --git a/cmd/internal/runtimeprovider/executable_other.go b/cmd/internal/runtimeprovider/executable_other.go new file mode 100644 index 000000000..c8d1e0e75 --- /dev/null +++ b/cmd/internal/runtimeprovider/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 runtimeprovider + +import "os" + +func validateHostExecutable(*os.File, string) error { return nil } diff --git a/cmd/internal/runtimeprovider/executable_windows.go b/cmd/internal/runtimeprovider/executable_windows.go new file mode 100644 index 000000000..2a8ebb380 --- /dev/null +++ b/cmd/internal/runtimeprovider/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 runtimeprovider + +import ( + "debug/pe" + "fmt" + "os" +) + +func validateHostExecutable(input *os.File, _ string) error { + file, err := pe.NewFile(input) + if err != nil { + return fmt.Errorf("runtime provider output is not a Windows executable: %w", err) + } + return file.Close() +} diff --git a/cmd/internal/runtimeprovider/flags.go b/cmd/internal/runtimeprovider/flags.go new file mode 100644 index 000000000..0d2c75b55 --- /dev/null +++ b/cmd/internal/runtimeprovider/flags.go @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package runtimeprovider + +import ( + "fmt" + "path/filepath" + "sort" + "strconv" + "strings" +) + +type parsedFlags struct { + graph GraphPolicy + build BuildPolicy + rejected []string +} + +// parseRuntimeFlags extracts the policy needed for discovery. Rejected build +// flags are retained and reported only after a runtime project is matched, so +// ordinary projects keep their existing behavior. +func parseRuntimeFlags(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...) + graphValues := make(map[string]string, 3) + graphSeen := make(map[string]int, 3) + var ret parsedFlags + ret.graph.GoCommand = goCommand + ret.graph.GoWork = goWork + for i, arg := range all { + name, value, ok := splitCanonicalFlag(arg) + if !ok { + ret.rejected = append(ret.rejected, arg) + continue + } + switch name { + case "mod": + switch value { + case "mod", "readonly", "vendor": + default: + // Keep malformed runtime-only policy deferred until a + // runtime project is selected. Legacy Go/XGo commands are + // still allowed to report the flag error themselves. + ret.rejected = append(ret.rejected, arg) + continue + } + graphValues[name] = value + graphSeen[name] = i + ret.graph.ModMode = value + 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 + } + graphValues[name] = path + graphSeen[name] = i + 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.Flags = replaceBuildFlag(ret.build.Flags, name, "-trimpath=true") + case "buildvcs": + if value != "false" { + ret.rejected = append(ret.rejected, "-"+name) + continue + } + ret.build.Flags = replaceBuildFlag(ret.build.Flags, name, "-buildvcs=false") + default: + ret.rejected = append(ret.rejected, "-"+name) + } + } + type graphFlag struct { + name string + value string + pos int + } + ordered := make([]graphFlag, 0, len(graphValues)) + for name, value := range graphValues { + ordered = append(ordered, graphFlag{name: name, value: value, pos: graphSeen[name]}) + } + sort.Slice(ordered, func(i, j int) bool { return ordered[i].pos < ordered[j].pos }) + for _, flag := range ordered { + ret.graph.Flags = append(ret.graph.Flags, "-"+flag.name+"="+flag.value) + } + return ret, nil +} + +func replaceBuildFlag(flags []string, name, value string) []string { + prefix := "-" + name + for i, flag := range flags { + if flag == prefix || strings.HasPrefix(flag, prefix+"=") { + flags[i] = value + return flags + } + } + return append(flags, value) +} + +func (p parsedFlags) validateRuntime() error { + if len(p.rejected) == 0 { + return nil + } + return fmt.Errorf("runtime provider v1 does not support flag %s", p.rejected[0]) +} + +func splitCanonicalFlag(arg string) (name, value string, ok bool) { + if !strings.HasPrefix(arg, "-") || arg == "-" || strings.HasPrefix(arg, "--") { + return "", "", false + } + 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 +} + +// splitQuotedFields implements the quoting accepted by GOFLAGS without +// invoking a shell. Quotes are removed and backslash quotes the next byte. +func splitQuotedFields(s string) ([]string, error) { + var fields []string + for i := 0; i < len(s); { + for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\r' || s[i] == '\n') { + i++ + } + if i == len(s) { + break + } + var b strings.Builder + quote := byte(0) + for i < len(s) { + c := s[i] + if quote == 0 && (c == ' ' || c == '\t' || c == '\r' || c == '\n') { + break + } + switch c { + case '\'', '"': + if quote == 0 { + quote = c + } else if quote == c { + quote = 0 + } else { + b.WriteByte(c) + } + case '\\': + i++ + if i == len(s) { + return nil, fmt.Errorf("trailing backslash") + } + b.WriteByte(s[i]) + default: + b.WriteByte(c) + } + i++ + } + if quote != 0 { + return nil, fmt.Errorf("unterminated quote") + } + fields = append(fields, b.String()) + } + return fields, nil +} diff --git a/cmd/internal/runtimeprovider/flags_test.go b/cmd/internal/runtimeprovider/flags_test.go new file mode 100644 index 000000000..35ea24635 --- /dev/null +++ b/cmd/internal/runtimeprovider/flags_test.go @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package runtimeprovider + +import ( + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestSplitQuotedFields(t *testing.T) { + got, err := splitQuotedFields(`-buildvcs=false '-overlay=a b.json' "-modfile=x.mod" -trimpath`) + if err != nil { + t.Fatal(err) + } + want := []string{"-buildvcs=false", "-overlay=a b.json", "-modfile=x.mod", "-trimpath"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("splitQuotedFields() = %#v, want %#v", got, want) + } + for _, in := range []string{`"unterminated`, `-x=foo\`} { + if _, err := splitQuotedFields(in); err == nil { + t.Fatalf("splitQuotedFields(%q) succeeded", in) + } + } +} + +func TestJoinQuotedFieldsRoundTrip(t *testing.T) { + want := []string{ + "-modfile=/workspace/with space/runtime.mod", + `-overlay=C:\\work tree\\overlay.json`, + `-overlay=/tmp/a\"quoted\".json`, + } + encoded := joinQuotedFields(want) + got, err := splitQuotedFields(encoded) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("splitQuotedFields(joinQuotedFields()) = %#v, want %#v (encoded %q)", got, want, encoded) + } +} + +func TestParseRuntimeFlags(t *testing.T) { + dir := t.TempDir() + got, err := parseRuntimeFlags(dir, "/usr/bin/go", "off", + `-buildvcs=false -mod=readonly -overlay='old overlay.json'`, + []string{"-v=true", "-x=false", "-work=true", "-trimpath=true", "-mod=mod", "-modfile=alt.mod"}) + if err != nil { + t.Fatal(err) + } + wantGraph := []string{ + "-overlay=" + filepath.Join(dir, "old overlay.json"), + "-mod=mod", + "-modfile=" + filepath.Join(dir, "alt.mod"), + } + if !reflect.DeepEqual(got.graph.Flags, wantGraph) { + t.Fatalf("graph flags = %#v, want %#v", got.graph.Flags, wantGraph) + } + if got.graph.ModMode != "mod" || !got.build.Verbose || got.build.Trace || !got.build.KeepWork { + t.Fatalf("unexpected policies: %#v", got) + } + if want := []string{"-buildvcs=false", "-trimpath=true"}; !reflect.DeepEqual(got.build.Flags, want) { + t.Fatalf("build flags = %#v, want %#v", got.build.Flags, want) + } + if err := got.validateRuntime(); err != nil { + t.Fatal(err) + } +} + +func TestParseRuntimeFlagsRejected(t *testing.T) { + for _, flag := range []string{"-n=true", "-tags=foo", "-buildmode=pie", "-buildvcs=true", "-trimpath=false"} { + got, err := parseRuntimeFlags(t.TempDir(), "/usr/bin/go", "off", "", []string{flag}) + if err != nil { + t.Fatalf("parseRuntimeFlags(%q): %v", flag, err) + } + if err := got.validateRuntime(); err == nil || !strings.Contains(err.Error(), strings.Split(strings.TrimPrefix(flag, "-"), "=")[0]) { + t.Fatalf("validateRuntime(%q) = %v", flag, err) + } + } +} diff --git a/cmd/internal/runtimeprovider/graph.go b/cmd/internal/runtimeprovider/graph.go new file mode 100644 index 000000000..a1be188cf --- /dev/null +++ b/cmd/internal/runtimeprovider/graph.go @@ -0,0 +1,552 @@ +/* + * 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 runtimeprovider + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/goplus/mod/modload" + gomodfile "golang.org/x/mod/modfile" +) + +var errNoGoModule = errors.New("runtime discovery requires a Go module") + +type fileIdentity = modload.FileIdentity + +type effectiveGraph struct { + Target ResolvedModule + Modules map[string]ResolvedModule + ClassModules []ResolvedModule + TargetModFile fileIdentity +} + +type goListModule struct { + Path string + Version string + Replace *goListModule + Main bool + Dir string + GoMod string + Error *struct { + Err string + } +} + +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, nil, "GOFLAGS") + if err != nil { + return parsedFlags{}, err + } + policy, err := parseRuntimeFlags(cwd, goCommand, "off", ambient, cli) + if err != nil { + return parsedFlags{}, err + } + policy = sanitizeGraphFlags(policy) + // 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, []string{}, "GOWORK") + 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 removes graph files that do not exist from discovery. +// Their original spelling is retained as a rejected runtime flag, so a +// matched runtime still reports the policy error through BuildPolicy while a +// legacy target can continue with its normal command path. +func sanitizeGraphFlags(policy parsedFlags) parsedFlags { + flags := make([]string, 0, len(policy.graph.Flags)) + for _, flag := range policy.graph.Flags { + name := "" + if strings.HasPrefix(flag, "-modfile=") { + name = "modfile" + } else if strings.HasPrefix(flag, "-overlay=") { + name = "overlay" + } + if name != "" { + path := strings.TrimPrefix(flag, "-"+name+"=") + if _, err := os.Stat(path); err != nil { + policy.rejected = append(policy.rejected, flag) + continue + } + } + flags = append(flags, flag) + } + policy.graph.Flags = flags + return policy +} + +func hostGoCommand() (string, error) { + path, err := exec.LookPath("go") + if err != nil { + return "", fmt.Errorf("host Go command: %w", err) + } + path, err = filepath.Abs(path) + if err != nil { + return "", err + } + if real, err := filepath.EvalSymlinks(path); err == nil { + path = real + } + info, err := os.Stat(path) + if err != nil { + return "", err + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("host Go command %q is not a regular file", path) + } + return path, nil +} + +func goEnvValue(ctx context.Context, goCommand, dir string, graphFlags []string, key string) (string, error) { + args := append([]string{"env"}, key) + cmd := exec.CommandContext(ctx, goCommand, args...) + cmd.Dir = dir + if graphFlags == nil { + cmd.Env = os.Environ() + } else { + cmd.Env = graphEnvironment(os.Environ(), "", graphFlags) + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return "", commandError("go env "+key, err, stderr.String()) + } + return strings.TrimSpace(string(out)), nil +} + +func loadEffectiveGraph(ctx context.Context, projectDir string, policy GraphPolicy) (*effectiveGraph, error) { + args := []string{"list", "-m", "-json"} + args = append(args, policy.Flags...) + args = append(args, "all") + cmd := exec.CommandContext(ctx, policy.GoCommand, args...) + cmd.Dir = projectDir + cmd.Env = graphEnvironment(os.Environ(), policy.GoWork, nil) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + message := stderr.String() + 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, commandError("go list -m", err, message) + } + var raw []goListModule + dec := json.NewDecoder(&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 alternate := graphFlagValue(policy.Flags, "modfile"); alternate != "" { + modfilePath = alternate + } + identity, classPaths, err := readTargetModFile(modfilePath) + 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 + 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 + cmd := exec.CommandContext(ctx, policy.GoCommand, "mod", "download", "-json", query) + cmd.Dir = dir + cmd.Env = graphEnvironment(os.Environ(), policy.GoWork, nil) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil { + return ResolvedModule{}, commandError("go mod download "+query, err, stderr.String()) + } + var downloaded goDownloadModule + if err := json.Unmarshal(stdout.Bytes(), &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) { + canonical, err := canonicalExistingFile(path) + if err != nil { + return fileIdentity{}, nil, fmt.Errorf("effective target modfile: %w", err) + } + data, err := os.ReadFile(canonical) + if err != nil { + return fileIdentity{}, nil, 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 +} + +func resolvePackageDirectory(graph *effectiveGraph, importPath string) (string, ResolvedModule, error) { + if importPath == "" || strings.Contains(importPath, "@") || strings.Contains(importPath, "...") { + return "", ResolvedModule{}, fmt.Errorf("runtime provider does not support package target %q", importPath) + } + 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 + suffix := strings.TrimPrefix(importPath, modulePath) + dir := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(suffix, "/"))) + canonical, err := canonicalExistingDir(dir) + if err != nil { + return "", ResolvedModule{}, fmt.Errorf("package target %q: %w", importPath, err) + } + if !pathWithin(root, canonical) { + return "", ResolvedModule{}, fmt.Errorf("package target %q escapes module %q", importPath, modulePath) + } + return canonical, module, nil + } + return "", ResolvedModule{}, fmt.Errorf("package target %q is outside the effective module graph", importPath) +} + +// retargetEffectiveGraph keeps the caller's selected build list and only +// changes which already-resolved module owns the class project. This is +// essential for package targets: running go list from a dependency directory +// would silently switch to that dependency's standalone graph. +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 := readTargetModFile(modfilePath) + 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, + }, nil +} + +func moduleContainsPackage(modulePath, packagePath string) bool { + return packagePath == modulePath || strings.HasPrefix(packagePath, modulePath+"/") +} + +func graphFlagValue(flags []string, name string) string { + prefix := "-" + name + "=" + for i := len(flags) - 1; i >= 0; i-- { + if strings.HasPrefix(flags[i], prefix) { + return strings.TrimPrefix(flags[i], prefix) + } + } + return "" +} + +func graphEnvironment(base []string, goWork string, graphFlags []string) []string { + env := replaceEnv(base, "GOFLAGS", joinQuotedFields(graphFlags)) + if goWork != "" { + env = replaceEnv(env, "GOWORK", goWork) + } + return env +} + +func joinQuotedFields(fields []string) string { + var joined strings.Builder + for i, field := range fields { + if i != 0 { + joined.WriteByte(' ') + } + joined.WriteByte('"') + for j := 0; j < len(field); j++ { + if field[j] == '\\' || field[j] == '"' { + joined.WriteByte('\\') + } + joined.WriteByte(field[j]) + } + joined.WriteByte('"') + } + return joined.String() +} + +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 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) +} + +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/runtimeprovider/graph_test.go b/cmd/internal/runtimeprovider/graph_test.go new file mode 100644 index 000000000..5d87ea940 --- /dev/null +++ b/cmd/internal/runtimeprovider/graph_test.go @@ -0,0 +1,244 @@ +/* + * 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 runtimeprovider + +import ( + "context" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +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", "provider")) + 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, "cmd", "provider", "main.go"), "package main\nfunc main() {}\n") + + policy, err := preparePolicies(context.Background(), app, nil) + if err != nil { + t.Fatal(err) + } + graph, err := loadEffectiveGraph(context.Background(), filepath.Join(app, "game"), policy.graph) + if err != nil { + t.Fatal(err) + } + 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(graph, "example.test/framework/cmd/provider") + if err != nil { + t.Fatal(err) + } + if dir != filepath.Join(framework, "cmd", "provider") || module.Selected.Path != "example.test/framework" { + t.Fatalf("resolved package = %q, %#v", dir, module) + } +} + +func TestLoadEffectiveGraphModfile(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + t.Setenv("GOWORK", "off") + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "go.mod"), "module example.test/app\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(dir, "runtime.mod"), "module example.test/app\n\ngo 1.25\n") + policy, err := preparePolicies(context.Background(), dir, []string{"-modfile=runtime.mod"}) + if err != nil { + t.Fatal(err) + } + graph, err := loadEffectiveGraph(context.Background(), dir, policy.graph) + if err != nil { + t.Fatal(err) + } + wantModfile, err := canonicalExistingFile(filepath.Join(dir, "runtime.mod")) + if err != nil { + t.Fatal(err) + } + if graph.TargetModFile.Path != wantModfile { + t.Fatalf("target modfile = %q", graph.TargetModFile.Path) + } +} + +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") + mustWriteFile(t, appGoMod, "module example.test/app\n\ngo 1.25\n") + 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 +) +`) + mustWriteFile(t, firstGoMod, "module example.test/first\n\ngo 1.25\n") + mustWriteFile(t, secondGoMod, "module example.test/second\n\ngo 1.25\n") + + 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, err := preparePolicies(context.Background(), t.TempDir(), nil) + if err != nil { + t.Fatal(err) + } + 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) + } +} + +func mustMkdirAll(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0755); err != nil { + t.Fatal(err) + } +} + +func mustWriteFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/internal/runtimeprovider/output.go b/cmd/internal/runtimeprovider/output.go new file mode 100644 index 000000000..eed3d736d --- /dev/null +++ b/cmd/internal/runtimeprovider/output.go @@ -0,0 +1,306 @@ +/* + * 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 runtimeprovider + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" +) + +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 resolveBuildOutput(cwd, requested, defaultName string) (string, error) { + if defaultName == "" { + return "", fmt.Errorf("empty runtime 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 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 +} + +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 runtime 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("runtime output parent: %w", err) + } + if !before.IsDir() { + return nil, fmt.Errorf("runtime output parent %q is not a directory", parent) + } + root, err := os.OpenRoot(parent) + if err != nil { + return nil, fmt.Errorf("open runtime output parent: %w", err) + } + pinned, err := root.Stat(".") + if err != nil { + _ = root.Close() + return nil, fmt.Errorf("inspect pinned runtime output parent: %w", err) + } + after, err := os.Stat(parent) + if err != nil { + _ = root.Close() + return nil, fmt.Errorf("revalidate runtime output parent: %w", err) + } + if !os.SameFile(before, pinned) || !os.SameFile(pinned, after) { + _ = root.Close() + return nil, fmt.Errorf("runtime 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 runtime output work directory: %w", err) + } + name := ".xgo-runtime-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 runtime output work directory: %w", err) + } + } + return "", fmt.Errorf("create runtime 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 runtime output %q: %w", displayPath, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("runtime output %q is a symlink", displayPath) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("runtime 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() +} + +func (tx *outputTransaction) commit() error { + if tx == nil || tx.closed { + return fmt.Errorf("runtime output transaction is closed") + } + if err := tx.checkParentPath(); err != nil { + return err + } + workInfo, err := tx.parent.Lstat(tx.workName) + if err != nil { + return fmt.Errorf("inspect runtime output work directory: %w", err) + } + if workInfo.Mode()&os.ModeSymlink != 0 || !workInfo.IsDir() || !os.SameFile(workInfo, tx.workIdentity) { + return fmt.Errorf("runtime output work directory changed during provider 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("runtime provider must create exactly one staged output") + } + info, err := tx.parent.Lstat(tx.stagedName) + if err != nil { + return fmt.Errorf("runtime provider output: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("runtime provider output %q is not a regular non-symlink file", tx.staged) + } + if info.Size() == 0 { + return fmt.Errorf("runtime provider output %q is empty", tx.staged) + } + if runtime.GOOS != "windows" && info.Mode().Perm()&0111 == 0 { + return fmt.Errorf("runtime provider output %q is not executable", tx.staged) + } + file, err := tx.parent.Open(tx.stagedName) + if err != nil { + return fmt.Errorf("open runtime provider output: %w", err) + } + openedInfo, err := file.Stat() + if err != nil { + _ = file.Close() + return fmt.Errorf("inspect open runtime provider output: %w", err) + } + if !os.SameFile(info, openedInfo) || !openedInfo.Mode().IsRegular() { + _ = file.Close() + return fmt.Errorf("runtime provider output changed while it was opened") + } + if err := file.Sync(); err != nil { + _ = file.Close() + return fmt.Errorf("sync runtime provider 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 runtime provider output: %w", err) + } + currentInfo, err := tx.parent.Lstat(tx.stagedName) + if err != nil || !os.SameFile(openedInfo, currentInfo) { + return fmt.Errorf("runtime provider 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("runtime provider output aliases existing final output") + } else if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("reinspect runtime output %q: %w", tx.final, err) + } + // The provider may run arbitrary build logic for a long time. Refuse to + // publish through the pinned handle if the user-visible parent pathname was + // renamed or replaced while that logic ran. + if err := tx.checkParentPath(); err != nil { + return err + } + 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 runtime output (final state %s): %w", state, err) + } + if err := tx.checkParentPath(); err != nil { + return err + } + // The rename above is the commit point. Cleanup and directory syncing are + // best effort from here so a successfully published output is never reported + // as a failed transaction. + 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 runtime output parent: %w", err) + } + current, err := os.Stat(tx.parentPath) + if err != nil { + return fmt.Errorf("revalidate runtime output parent %q: %w", tx.parentPath, err) + } + if !current.IsDir() || !os.SameFile(pinned, current) { + return fmt.Errorf("runtime output parent %q changed during provider 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/runtimeprovider/output_test.go b/cmd/internal/runtimeprovider/output_test.go new file mode 100644 index 000000000..602469a81 --- /dev/null +++ b/cmd/internal/runtimeprovider/output_test.go @@ -0,0 +1,309 @@ +/* + * 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 runtimeprovider + +import ( + "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.commit(); 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.commit(); 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 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.commit(); 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.commit(); 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.commit() + 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.commit(); 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.commit(); 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.commit(); 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) + } +} diff --git a/cmd/internal/runtimeprovider/process_unix.go b/cmd/internal/runtimeprovider/process_unix.go new file mode 100644 index 000000000..417343427 --- /dev/null +++ b/cmd/internal/runtimeprovider/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 runtimeprovider + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "syscall" + "time" +) + +const ( + providerProcessGracePeriod = 2 * time.Second + providerProcessKillWait = 2 * time.Second +) + +func runProviderProcess(ctx context.Context, cmd *exec.Cmd) (ProcessStatus, error) { + if err := ctx.Err(); err != nil { + return ProcessStatus{}, err + } + configureProviderProcessGroup(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 := stopProviderProcessGroup(pgid, syscall.SIGTERM, nil); cleanupErr != nil { + return ProcessStatus{}, cleanupErr + } + return processStatus(err) + case <-ctx.Done(): + } + + initial := providerCancellationSignal(ctx) + err, cleanupErr := stopProviderProcessGroup(pgid, initial, wait) + if cleanupErr != nil { + return ProcessStatus{}, cleanupErr + } + return processStatus(err) +} + +func configureProviderProcessGroup(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 providerCancellationSignal(ctx context.Context) syscall.Signal { + var cause runtimeSignalCause + if errors.As(context.Cause(ctx), &cause) && cause.signal != 0 { + return cause.signal + } + return syscall.SIGTERM +} + +// stopProviderProcessGroup 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 stopProviderProcessGroup(pgid int, initial syscall.Signal, wait <-chan error) (error, error) { + state := providerProcessWait{wait: wait, leaderDone: wait == nil} + groupDone := !providerProcessGroupExists(pgid) + if state.leaderDone && groupDone { + return state.leaderErr, nil + } + var signalErr error + if err := signalProviderProcessGroup(pgid, initial); err != nil && !errors.Is(err, syscall.ESRCH) { + signalErr = fmt.Errorf("signal runtime provider process group: %w", err) + } + + groupDone = state.waitForProcessGroup(pgid, providerProcessGracePeriod) + if state.leaderDone && groupDone { + return state.leaderErr, signalErr + } + if err := signalProviderProcessGroup(pgid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { + signalErr = errors.Join(signalErr, fmt.Errorf("kill runtime provider process group: %w", err)) + } + groupDone = state.waitForProcessGroup(pgid, providerProcessKillWait) + if !state.leaderDone { + return state.leaderErr, errors.Join(signalErr, fmt.Errorf("runtime provider did not exit after SIGKILL")) + } + if !groupDone { + return state.leaderErr, errors.Join(signalErr, fmt.Errorf("runtime provider descendants did not exit after SIGKILL")) + } + return state.leaderErr, signalErr +} + +type providerProcessWait struct { + wait <-chan error + leaderDone bool + leaderErr error +} + +func (s *providerProcessWait) 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 := !providerProcessGroupExists(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 !providerProcessGroupExists(pgid) + } + } +} + +func signalProviderProcessGroup(pgid int, sig syscall.Signal) error { + return syscall.Kill(-pgid, sig) +} + +func providerProcessGroupExists(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/runtimeprovider/process_unix_test.go b/cmd/internal/runtimeprovider/process_unix_test.go new file mode 100644 index 000000000..c4438f97c --- /dev/null +++ b/cmd/internal/runtimeprovider/process_unix_test.go @@ -0,0 +1,245 @@ +//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 runtimeprovider + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "syscall" + "testing" + "time" +) + +const runtimeProviderHelperEnv = "XGO_RUNTIME_PROVIDER_PROCESS_HELPER" + +type providerProcessResult struct { + status ProcessStatus + err error +} + +func TestRunProviderProcessForwardsRuntimeSignal(t *testing.T) { + dir := t.TempDir() + ready := filepath.Join(dir, "ready") + received := filepath.Join(dir, "received") + cmd := runtimeProviderHelperCommand("handle", ready, received, strconv.Itoa(int(syscall.SIGHUP))) + ctx, cancel := context.WithCancelCause(context.Background()) + t.Cleanup(func() { cancel(nil) }) + result := make(chan providerProcessResult, 1) + go func() { + status, err := runProviderProcess(ctx, cmd) + result <- providerProcessResult{status: status, err: err} + }() + pid := waitForHelperPID(t, ready) + t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) }) + + cancel(runtimeSignalCause{signal: syscall.SIGHUP}) + got := waitForProviderProcessResult(t, result) + if got.err != nil || got.status.Signaled || got.status.Code != 0 { + t.Fatalf("runProviderProcess() = (%+v, %v), want graceful exit", got.status, got.err) + } + if signal := waitForHelperSignal(t, received); signal != syscall.SIGHUP { + t.Fatalf("provider received %v, want SIGHUP", signal) + } +} + +func TestRunProviderProcessEscalatesIgnoredCancellation(t *testing.T) { + dir := t.TempDir() + ready := filepath.Join(dir, "ready") + received := filepath.Join(dir, "received") + cmd := runtimeProviderHelperCommand("resist", ready, received, strconv.Itoa(int(syscall.SIGTERM))) + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan providerProcessResult, 1) + go func() { + status, err := runProviderProcess(ctx, cmd) + result <- providerProcessResult{status: status, err: err} + }() + pid := waitForHelperPID(t, ready) + t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) }) + + cancel() + got := waitForProviderProcessResult(t, result) + if got.err != nil || !got.status.Signaled || got.status.Signal != syscall.SIGKILL { + t.Fatalf("runProviderProcess() = (%+v, %v), want SIGKILL after grace period", got.status, got.err) + } + if signal := waitForHelperSignal(t, received); signal != syscall.SIGTERM { + t.Fatalf("provider received %v before escalation, want SIGTERM", signal) + } +} + +func TestRunProviderProcessCleansDescendantsAfterLeaderExit(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 := runtimeProviderHelperCommand("spawn-descendant", childPID, childReady, childSignal) + + status, err := runProviderProcess(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("runProviderProcess() = (%+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 provider return: %v", pid, err) + } +} + +func TestRuntimeProviderProcessHelper(t *testing.T) { + if os.Getenv(runtimeProviderHelperEnv) != "1" { + return + } + args := runtimeProviderHelperArgs() + if len(args) == 0 { + t.Fatal("missing helper mode") + } + switch args[0] { + case "handle": + runRuntimeProviderSignalHelper(t, args[1:], true) + case "resist": + runRuntimeProviderSignalHelper(t, args[1:], false) + case "spawn-descendant": + if len(args) != 4 { + t.Fatalf("spawn-descendant args = %q", args) + } + child := runtimeProviderHelperCommand("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 runRuntimeProviderSignalHelper(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 runtimeProviderHelperCommand(args ...string) *exec.Cmd { + commandArgs := []string{"-test.run=^TestRuntimeProviderProcessHelper$", "--"} + commandArgs = append(commandArgs, args...) + cmd := exec.Command(os.Args[0], commandArgs...) + cmd.Env = append(os.Environ(), runtimeProviderHelperEnv+"=1") + return cmd +} + +func runtimeProviderHelperArgs() []string { + for i, arg := range os.Args { + if arg == "--" { + return os.Args[i+1:] + } + } + return nil +} + +func waitForProviderProcessResult(t *testing.T, result <-chan providerProcessResult) providerProcessResult { + t.Helper() + select { + case got := <-result: + return got + case <-time.After(5 * time.Second): + t.Fatal("runtime provider process did not return") + return providerProcessResult{} + } +} + +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/runtimeprovider/process_windows.go b/cmd/internal/runtimeprovider/process_windows.go new file mode 100644 index 000000000..39602e105 --- /dev/null +++ b/cmd/internal/runtimeprovider/process_windows.go @@ -0,0 +1,133 @@ +//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 runtimeprovider + +import ( + "context" + "fmt" + "os" + "os/exec" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ntResumeProcess = windows.NewLazySystemDLL("ntdll.dll").NewProc("NtResumeProcess") + +func runProviderProcess(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 + } + configureSuspendedProvider(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 { + abortSuspendedProvider(cmd, 0) + return ProcessStatus{}, err + } + defer windows.CloseHandle(process) + if err := windows.AssignProcessToJobObject(job, process); err != nil { + abortSuspendedProvider(cmd, process) + return ProcessStatus{}, err + } + if err := resumeProviderProcess(process); err != nil { + terminateProviderJob(job, process) + _ = cmd.Wait() + return ProcessStatus{}, err + } + done := make(chan struct{}) + watchDone := make(chan struct{}) + go func() { + defer close(watchDone) + select { + case <-ctx.Done(): + terminateProviderJob(job, process) + case <-done: + } + }() + err = cmd.Wait() + close(done) + <-watchDone + return processStatus(err) +} + +func configureSuspendedProvider(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 resumeProviderProcess(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 runtime provider: %w", status) + } + return nil +} + +func abortSuspendedProvider(cmd *exec.Cmd, process windows.Handle) { + if process != 0 { + _ = windows.TerminateProcess(process, 1) + } else { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() +} + +func terminateProviderJob(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/runtimeprovider/process_windows_test.go b/cmd/internal/runtimeprovider/process_windows_test.go new file mode 100644 index 000000000..c88f34233 --- /dev/null +++ b/cmd/internal/runtimeprovider/process_windows_test.go @@ -0,0 +1,105 @@ +//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 runtimeprovider + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +func TestConfigureSuspendedProviderPreservesFlags(t *testing.T) { + cmd := exec.Command("provider.exe") + cmd.SysProcAttr = &windows.SysProcAttr{CreationFlags: windows.CREATE_NO_WINDOW} + original := cmd.SysProcAttr + configureSuspendedProvider(cmd) + if cmd.SysProcAttr == original { + t.Fatal("configureSuspendedProvider 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 TestRunProviderProcessWindows(t *testing.T) { + if os.Getenv("XGO_TEST_WINDOWS_PROVIDER_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, "provider-stderr") + return + } + + work := t.TempDir() + cmd := exec.Command(os.Args[0], "-test.run=^TestRunProviderProcessWindows$", "--", "a b", `c"d`) + cmd.Dir = work + cmd.Env = append(os.Environ(), "XGO_TEST_WINDOWS_PROVIDER_CHILD=1", "XGO_TEST_VALUE=present") + cmd.Stdin = strings.NewReader("provider-stdin") + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + status, err := runProviderProcess(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=provider-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() != "provider-stderr" { + t.Fatalf("stderr = %q", stderr.String()) + } +} + +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/runtimeprovider/protocol.go b/cmd/internal/runtimeprovider/protocol.go new file mode 100644 index 000000000..4f58effd5 --- /dev/null +++ b/cmd/internal/runtimeprovider/protocol.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 runtimeprovider + +import ( + "fmt" + "runtime" + "strings" + "unicode/utf16" + + "github.com/goplus/mod/runtimeprotocol" + "github.com/goplus/mod/xgomod" +) + +func providerArgs(rt *Runtime, act action, policy BuildPolicy, output, finalOutput string, appArgs []string) ([]string, error) { + if rt.Protocol != protocolV1 { + return nil, fmt.Errorf("unsupported runtime provider protocol %q", rt.Protocol) + } + var pack *runtimeprotocol.Pack + if rt.PackDir != "" || rt.PackIndex != "" { + if rt.PackDir == "" || rt.PackIndex == "" { + return nil, fmt.Errorf("runtime pack metadata must contain both directory and index") + } + pack = &runtimeprotocol.Pack{Directory: rt.PackDir, IndexFile: rt.PackIndex} + } + request := runtimeprotocol.Request{ + Version: protocolV1, + Action: act, + Project: runtimeprotocol.Project{ + Dir: rt.ProjectDir, + File: rt.ProjectFile, + ModuleRoot: rt.ModuleRoot, + Extension: rt.ProjectExt, + FullExtension: rt.ProjectFullExt, + Pack: pack, + }, + ProviderPackage: rt.ProviderPackage, + ProviderOrigin: rt.Origin, + Declaration: xgomod.FileIdentity{Path: rt.GoxMod, SHA256: rt.GoxModSHA256}, + Graph: runtimeprotocol.Graph{ + GoCommand: rt.Graph.GoCommand, + WorkDir: rt.Graph.WorkDir, + GoWork: rt.Graph.GoWork, + Flags: append([]string(nil), rt.Graph.Flags...), + }, + 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 = &runtimeprotocol.BuildOutput{Staging: output, Final: finalOutput} + default: + return nil, fmt.Errorf("unsupported runtime provider action %q", act) + } + return runtimeprotocol.Encode(request) +} + +func (p BuildPolicy) protocolFlags() []string { + flags := make([]string, 0, len(p.Flags)+3) + if p.Verbose { + flags = append(flags, "-v=true") + } + if p.Trace { + flags = append(flags, "-x=true") + } + if p.KeepWork { + flags = append(flags, "-work=true") + } + return append(flags, p.Flags...) +} + +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 ErrRuntimeArgvTooLarge + } + envUnits := 1 + for _, item := range env { + envUnits += len(utf16.Encode([]rune(item))) + 1 + } + if envUnits > 32_767 { + return ErrRuntimeArgvTooLarge + } + 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 ErrRuntimeArgvTooLarge + } + 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/runtimeprovider/protocol_test.go b/cmd/internal/runtimeprovider/protocol_test.go new file mode 100644 index 000000000..0045925b6 --- /dev/null +++ b/cmd/internal/runtimeprovider/protocol_test.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 runtimeprovider + +import ( + "errors" + "reflect" + "strings" + "testing" +) + +func testRuntime() *Runtime { + return &Runtime{ + ProjectDir: "/project", + ProjectFile: "/project/main.foo", + ModuleRoot: "/project", + ProviderPackage: "example.test/framework/cmd/provider", + 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", + Flags: []string{"-mod=mod", "-modfile=/project/alt.mod"}, + }, + } +} + +func TestProviderArgsRun(t *testing.T) { + rt := testRuntime() + got, err := providerArgs(rt, actionRun, BuildPolicy{Flags: []string{"-trimpath=true"}, Verbose: true, Trace: true, KeepWork: true}, "", "", []string{"", "a b", "--"}) + if err != nil { + t.Fatal(err) + } + want := []string{ + "xgo-runtime-v1", + "run", + "--project-dir=/project", + "--project-file=/project/main.foo", + "--module-root=/project", + "--provider-package=example.test/framework/cmd/provider", + "--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 TestProviderArgsBuildSelected(t *testing.T) { + rt := testRuntime() + rt.Origin.Replace = nil + rt.Origin.Selected.Dir = "/framework" + rt.Origin.Selected.GoMod = "/framework/go.mod" + got, err := providerArgs(rt, 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 TestProviderArgsInvalid(t *testing.T) { + rt := testRuntime() + rt.Protocol = "v2" + if _, err := providerArgs(rt, actionRun, BuildPolicy{}, "", "", nil); err == nil { + t.Fatal("unsupported protocol succeeded") + } + rt.Protocol = "v1" + if _, err := providerArgs(rt, 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("provider", []string{big}, nil); !errors.Is(err, ErrRuntimeArgvTooLarge) { + t.Fatalf("validateArgv = %v", err) + } +} diff --git a/cmd/internal/runtimeprovider/provider.go b/cmd/internal/runtimeprovider/provider.go new file mode 100644 index 000000000..9fd30b7f6 --- /dev/null +++ b/cmd/internal/runtimeprovider/provider.go @@ -0,0 +1,316 @@ +/* + * 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 runtimeprovider + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +type goListPackage struct { + Dir string + ImportPath string + Name string + Module *goListModule + Error *struct { + Err string + } +} + +type builtProvider struct { + path string + dir string + keep bool +} + +func (p *builtProvider) cleanup() { + if p != nil && !p.keep { + _ = os.RemoveAll(p.dir) + } +} + +func validateProvider(ctx context.Context, rt *Runtime) error { + if !moduleContainsPackage(rt.Origin.Selected.Path, rt.ProviderPackage) { + return fmt.Errorf("runtime provider package %q is outside declaring module %q", rt.ProviderPackage, rt.Origin.Selected.Path) + } + args := []string{"list", "-json"} + args = append(args, rt.Graph.Flags...) + args = append(args, rt.ProviderPackage) + cmd := exec.CommandContext(ctx, rt.Graph.GoCommand, args...) + cmd.Dir = rt.Graph.WorkDir + cmd.Env = graphEnvironment(os.Environ(), rt.Graph.GoWork, nil) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil { + return commandError("validate runtime provider", err, stderr.String()) + } + var pkg goListPackage + if err := json.Unmarshal(stdout.Bytes(), &pkg); err != nil { + return fmt.Errorf("decode runtime provider package: %w", err) + } + if pkg.Error != nil && pkg.Error.Err != "" { + return fmt.Errorf("runtime provider package: %s", pkg.Error.Err) + } + if pkg.ImportPath != rt.ProviderPackage { + return fmt.Errorf("runtime provider resolved as %q, want %q", pkg.ImportPath, rt.ProviderPackage) + } + if pkg.Name != "main" { + return fmt.Errorf("runtime provider package %q is %q, want command package main", rt.ProviderPackage, pkg.Name) + } + if pkg.Module == nil { + return fmt.Errorf("runtime provider package %q has no module provenance", rt.ProviderPackage) + } + module, err := normalizeListedModule(*pkg.Module) + if err != nil { + return err + } + if !sameResolvedModule(module, rt.Origin) { + return fmt.Errorf("runtime provider package %q does not match declaring module provenance", rt.ProviderPackage) + } + providerDir, err := canonicalExistingDir(pkg.Dir) + if err != nil { + return fmt.Errorf("runtime provider directory: %w", err) + } + if !pathWithin(rt.Origin.Effective().Dir, providerDir) { + return fmt.Errorf("runtime provider directory escapes declaring module") + } + return nil +} + +func sameResolvedModule(a, b ResolvedModule) bool { + if a.Main != b.Main || a.Selected != b.Selected { + return false + } + if a.Replace == nil || b.Replace == nil { + return a.Replace == nil && b.Replace == nil + } + return *a.Replace == *b.Replace +} + +func buildProvider(ctx context.Context, rt *Runtime, policy BuildPolicy, streams Streams) (*builtProvider, error) { + if goos := os.Getenv("GOOS"); goos != "" && goos != runtime.GOOS { + return nil, fmt.Errorf("runtime providers are host-only: GOOS=%s, host=%s", goos, runtime.GOOS) + } + if goarch := os.Getenv("GOARCH"); goarch != "" && goarch != runtime.GOARCH { + return nil, fmt.Errorf("runtime providers are host-only: GOARCH=%s, host=%s", goarch, runtime.GOARCH) + } + if err := validateProvider(ctx, rt); err != nil { + return nil, err + } + dir, err := os.MkdirTemp("", "xgo-runtime-provider-") + if err != nil { + return nil, err + } + if err := os.Chmod(dir, 0700); err != nil { + _ = os.RemoveAll(dir) + return nil, err + } + provider := &builtProvider{dir: dir, path: filepath.Join(dir, executableName("provider")), keep: policy.KeepWork} + args := []string{"build"} + args = append(args, rt.Graph.Flags...) + args = append(args, policy.goBuildFlags()...) + args = append(args, "-buildmode=exe", "-o", provider.path, rt.ProviderPackage) + cmd := exec.CommandContext(ctx, rt.Graph.GoCommand, args...) + cmd.Dir = rt.Graph.WorkDir + cmd.Env = hostBuildEnvironment(os.Environ(), rt.Graph.GoWork) + cmd.Stdin, cmd.Stdout, cmd.Stderr = streams.Stdin, streams.Stdout, streams.Stderr + if policy.Trace { + fmt.Fprintln(streams.Stderr, redactCommand(rt.Graph.GoCommand, args)) + } + if err := cmd.Run(); err != nil { + provider.cleanup() + return nil, fmt.Errorf("build runtime provider %q: %w", rt.ProviderPackage, err) + } + info, err := os.Lstat(provider.path) + if err != nil { + provider.cleanup() + return nil, fmt.Errorf("runtime provider build output: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() == 0 { + provider.cleanup() + return nil, fmt.Errorf("runtime provider build did not produce a regular executable") + } + if policy.KeepWork { + fmt.Fprintf(streams.Stderr, "XGO_RUNTIME_PROVIDER_WORK=%s\n", dir) + } + return provider, nil +} + +func (p BuildPolicy) goBuildFlags() []string { + flags := make([]string, 0, len(p.Flags)+3) + if p.Verbose { + flags = append(flags, "-v") + } + if p.Trace { + flags = append(flags, "-x") + } + if p.KeepWork { + flags = append(flags, "-work") + } + return append(flags, p.Flags...) +} + +func hostBuildEnvironment(base []string, goWork string) []string { + env := graphEnvironment(base, goWork, nil) + env = replaceEnv(env, "GOOS", runtime.GOOS) + env = replaceEnv(env, "GOARCH", runtime.GOARCH) + return env +} + +func providerEnvironment(base []string, rt *Runtime) []string { + env := hostBuildEnvironment(base, rt.Graph.GoWork) + return replaceEnv(env, runtimeGuardEnv, runtimeGuard(rt.ProjectDir, rt.ProviderPackage)) +} + +func (r *Resolver) Run(ctx context.Context, rt *Runtime, appArgs []string, streams Streams) (ProcessStatus, error) { + policy, err := r.BuildPolicy() + if err != nil { + return ProcessStatus{}, err + } + return execute(ctx, rt, actionRun, policy, "", "", appArgs, streams) +} + +func (r *Resolver) Build(ctx context.Context, rt *Runtime, requestedOutput string, streams Streams) (ProcessStatus, string, error) { + policy, err := r.BuildPolicy() + if err != nil { + return ProcessStatus{}, "", err + } + // execute fills streams for the provider process, but Build also reports + // kept work directories after execute returns. Normalize once here so a + // caller may omit Stderr without making the post-build report panic. + streams = fillStreams(streams) + final, err := resolveBuildOutput(r.cwd, requestedOutput, rt.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, rt, actionBuild, policy, tx.staged, final, nil, streams) + if err != nil || status.Signaled || status.Code != 0 { + return status, final, err + } + if err := tx.commit(); err != nil { + return ProcessStatus{}, final, err + } + if policy.KeepWork { + fmt.Fprintf(streams.Stderr, "XGO_RUNTIME_OUTPUT_WORK=%s\n", tx.dir) + } + return successStatus(), final, nil +} + +// Install builds one runtime target transactionally into the effective GOBIN. +func (r *Resolver) Install(ctx context.Context, rt *Runtime, streams Streams) (ProcessStatus, string, error) { + bin, err := installBin(ctx, rt.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.Build(ctx, rt, filepath.Join(bin, rt.DefaultExecName), streams) +} + +func installBin(ctx context.Context, graph GraphPolicy) (string, error) { + cmd := exec.CommandContext(ctx, graph.GoCommand, "env", "-json", "GOBIN", "GOPATH") + cmd.Dir = graph.WorkDir + cmd.Env = graphEnvironment(os.Environ(), graph.GoWork, nil) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil { + return "", commandError("resolve install directory", err, stderr.String()) + } + values := make(map[string]string) + if err := json.Unmarshal(stdout.Bytes(), &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, rt *Runtime, act action, policy BuildPolicy, output, finalOutput string, appArgs []string, streams Streams) (ProcessStatus, error) { + streams = fillStreams(streams) + provider, err := buildProvider(ctx, rt, policy, streams) + if err != nil { + return ProcessStatus{}, err + } + defer provider.cleanup() + args, err := providerArgs(rt, act, policy, output, finalOutput, appArgs) + if err != nil { + return ProcessStatus{}, err + } + env := providerEnvironment(os.Environ(), rt) + if err := validateArgv(provider.path, args, env); err != nil { + return ProcessStatus{}, err + } + if policy.Trace { + fmt.Fprintln(streams.Stderr, redactCommand(provider.path, args)) + } + cmd := exec.Command(provider.path, args...) + cmd.Dir = rt.ProjectDir + cmd.Env = env + cmd.Stdin, cmd.Stdout, cmd.Stderr = streams.Stdin, streams.Stdout, streams.Stderr + return runProviderProcess(ctx, cmd) +} + +func fillStreams(streams Streams) Streams { + defaults := defaultStreams() + if streams.Stdin == nil { + streams.Stdin = defaults.Stdin + } + if streams.Stdout == nil { + streams.Stdout = defaults.Stdout + } + if streams.Stderr == nil { + streams.Stderr = defaults.Stderr + } + return streams +} + +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 +} diff --git a/cmd/internal/runtimeprovider/provider_test.go b/cmd/internal/runtimeprovider/provider_test.go new file mode 100644 index 000000000..a0958ffe5 --- /dev/null +++ b/cmd/internal/runtimeprovider/provider_test.go @@ -0,0 +1,55 @@ +/* + * 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 runtimeprovider + +import ( + "runtime" + "strings" + "testing" +) + +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) + } +} + +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 +} diff --git a/cmd/internal/runtimeprovider/resolve.go b/cmd/internal/runtimeprovider/resolve.go new file mode 100644 index 000000000..2b60880bf --- /dev/null +++ b/cmd/internal/runtimeprovider/resolve.go @@ -0,0 +1,746 @@ +/* + * 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 runtimeprovider + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/goplus/mod/modfile" + "github.com/goplus/mod/modload" + "github.com/goplus/mod/xgomod" + "github.com/goplus/xgo/env" + "github.com/goplus/xgo/x/xgoprojs" + gomodfile "golang.org/x/mod/modfile" +) + +const runtimeGuardEnv = "XGO_RUNTIME_GUARD" + +func runtimeGuard(projectDir, providerPackage string) string { + return sha256Bytes([]byte(projectDir + "\x00" + providerPackage)) +} + +// Resolver owns one invocation's graph and host-build policies. +type Resolver struct { + cwd string + policy parsedFlags + policyErr error + xgoVersion string +} + +// NewResolver snapshots ambient GOFLAGS/GOWORK once for an invocation. A +// policy setup error is retained for BuildPolicy instead of being returned +// immediately: discovery must be able to return ErrNotHandled for an +// ordinary legacy target before runtime-only policy errors are surfaced. +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 &Resolver{cwd: cwd, policy: policy, xgoVersion: env.Version()}, nil + } + // Use a conservative, no-ambient-policy fallback for discovery. The + // retained error is checked only when execution asks for BuildPolicy. + fallback := parsedFlags{graph: GraphPolicy{GoCommand: "go", GoWork: "off"}} + if command, commandErr := hostGoCommand(); commandErr == nil { + fallback.graph.GoCommand = command + } + if parsed, parseErr := parseRuntimeFlags(cwd, fallback.graph.GoCommand, "off", "", flags); parseErr == nil { + fallback = sanitizeGraphFlags(parsed) + if fallback.graph.GoWork == "" { + fallback.graph.GoWork = "off" + } + } + return &Resolver{cwd: cwd, policy: fallback, policyErr: err, xgoVersion: env.Version()}, nil +} + +// BuildPolicy returns the validated runtime build policy. Callers must invoke +// it only after Resolve matched a runtime project. +func (r *Resolver) BuildPolicy() (BuildPolicy, error) { + if r.policyErr != nil { + return BuildPolicy{}, r.policyErr + } + if err := r.policy.validateRuntime(); 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) (*Runtime, error) { + var ( + kind TargetKind + original string + projectDir string + targetImportPath string + expectedFile string + multiFile bool + unsupportedForm string + graph *effectiveGraph + graphVendor bool + graphWorkDir string + ) + switch target := target.(type) { + case *xgoprojs.DirProj: + kind, original = TargetDirectory, target.Dir + candidate := target.Dir + if hasRecursivePattern(candidate) { + unsupportedForm = "directory pattern containing ..." + candidate = trimRecursivePattern(candidate) + } + var err error + projectDir, err = canonicalExistingDir(candidate) + if err != nil { + if os.IsNotExist(errors.Unwrap(err)) || os.IsNotExist(err) { + return nil, ErrNotHandled + } + return nil, err + } + graphWorkDir = projectDir + case *xgoprojs.FilesProj: + if len(target.Files) == 0 { + return nil, ErrNotHandled + } + kind, original = TargetFile, target.Files[0] + multiFile = len(target.Files) != 1 + file, err := canonicalExistingFile(target.Files[0]) + if err != nil { + return nil, ErrNotHandled + } + expectedFile = file + projectDir = filepath.Dir(file) + graphWorkDir = projectDir + case *xgoprojs.PkgPathProj: + kind, original, targetImportPath = TargetPackage, target.Path, target.Path + lookupPath := target.Path + if strings.Contains(lookupPath, "@") { + unsupportedForm = "package target containing @version" + lookupPath = strings.SplitN(lookupPath, "@", 2)[0] + } + if hasRecursivePattern(lookupPath) { + unsupportedForm = "package pattern containing ..." + lookupPath = trimRecursivePattern(lookupPath) + } + preflightModule, preflightGoMod, callerHasClass, vendor, err := r.preflightClassMetadataDetails(ctx, r.cwd) + if err != nil && !errors.Is(err, errNoGoModule) { + return nil, err + } + callerGraph, err := loadEffectiveGraph(ctx, r.cwd, r.policy.graph) + if err != nil { + if callerHasClass && vendor { + hasRuntime, probeErr := r.probeVendorPackage(ctx, preflightGoMod, preflightModule, lookupPath) + if probeErr != nil { + return nil, probeErr + } + if hasRuntime { + return nil, vendorUnsupportedError(r.policy.graph.ModMode) + } + return nil, ErrNotHandled + } + if errors.Is(err, errNoGoModule) && !callerHasClass { + return nil, ErrNotHandled + } + return nil, err + } + var targetModule ResolvedModule + projectDir, targetModule, err = resolvePackageDirectory(callerGraph, lookupPath) + if err != nil { + if callerHasClass { + return nil, err + } + return nil, ErrNotHandled + } + // A package target may use runtime metadata from its own main/workspace + // module. A non-main dependency is eligible only when the caller's exact + // target modfile snapshot marked that logical module as an XGo class + // dependency. Do not let retargeting discover an unmarked dependency's + // ambient gox.mod and expand the provider execution trust boundary. + if !targetModule.Main && !classModuleMarked(callerGraph.ClassModules, targetModule.Selected.Path) { + return nil, ErrNotHandled + } + graph, err = retargetEffectiveGraph(callerGraph, targetModule) + if err != nil { + return nil, err + } + graphVendor = vendor + graphWorkDir = r.cwd + default: + return nil, ErrNotHandled + } + graphPolicy := r.policy.graph + graphPolicy.WorkDir = graphWorkDir + + if graph == nil { + preflightModule, preflightGoMod, hasClass, vendor, err := r.preflightClassMetadataDetails(ctx, projectDir) + if err != nil { + if errors.Is(err, errNoGoModule) { + return nil, ErrNotHandled + } + return nil, err + } + if !hasClass { + return nil, ErrNotHandled + } + if vendor { + hasRuntime, probeErr := r.probeVendorProject(ctx, projectDir, preflightGoMod, preflightModule) + if probeErr != nil { + return nil, probeErr + } + if !hasRuntime { + return nil, ErrNotHandled + } + return nil, vendorUnsupportedError(r.policy.graph.ModMode) + } + graph, err = loadEffectiveGraph(ctx, projectDir, r.policy.graph) + if err != nil { + return nil, err + } + } else { + hasClass, err := graphHasClassMetadata(graph) + if err != nil { + return nil, err + } + if !hasClass { + return nil, ErrNotHandled + } + } + module, hasRuntimeMetadata, err := loadResolvedClasses(graph) + if err != nil { + return nil, err + } + if unsupportedForm != "" && hasRuntimeMetadata { + return nil, fmt.Errorf("runtime provider v1 does not support %s", unsupportedForm) + } + projectFile, info, candidates, err := findProjectFile(projectDir, module) + if err != nil { + return nil, err + } + if info == nil || info.Project.Runtime == nil { + return nil, ErrNotHandled + } + if graphVendor { + return nil, vendorUnsupportedError(r.policy.graph.ModMode) + } + if candidates != 1 { + return nil, fmt.Errorf("runtime project directory %q contains %d project files; exactly one is required", projectDir, candidates) + } + if multiFile { + return nil, fmt.Errorf("runtime provider v1 does not support multiple source-file targets") + } + if expectedFile != "" { + same, err := sameFile(expectedFile, projectFile) + if err != nil || !same { + return nil, fmt.Errorf("runtime file target %q is not the unique project file %q", original, projectFile) + } + } + if info.Origin == nil { + return nil, fmt.Errorf("runtime project %q has no module provenance", projectFile) + } + if err := checkRequiredXGo(info.RequiredXGo, r.xgoVersion); err != nil { + return nil, err + } + project := info.Project + if project.Runtime.Protocol != protocolV1 { + return nil, fmt.Errorf("unsupported runtime provider protocol %q", project.Runtime.Protocol) + } + packDir, packIndex, err := validatePack(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_RUNTIME") == "off" { + return nil, ErrRuntimeDisabled + } + guard := runtimeGuard(projectDir, project.Runtime.Package) + if os.Getenv(runtimeGuardEnv) == guard { + return nil, ErrRuntimeRecursive + } + defaultName := defaultExecutableName(kind, projectDir, targetImportPath) + return &Runtime{ + TargetKind: kind, + OriginalTarget: original, + TargetImportPath: targetImportPath, + DefaultExecName: defaultName, + ProjectDir: projectDir, + ProjectFile: projectFile, + ModuleRoot: graph.Target.Effective().Dir, + ProviderPackage: project.Runtime.Package, + Origin: origin, + RequiredXGo: info.RequiredXGo, + Protocol: project.Runtime.Protocol, + ProjectExt: project.Ext, + ProjectFullExt: project.FullExt, + PackDir: packDir, + PackIndex: packIndex, + GoxMod: goxmod.Path, + GoxModSHA256: goxmod.SHA256, + Graph: graphPolicy, + }, nil +} + +func classModuleMarked(classModules []ResolvedModule, modulePath string) bool { + for _, module := range classModules { + if module.Selected.Path == modulePath { + return true + } + } + return false +} + +func (r *Resolver) preflightClassMetadata(ctx context.Context, dir string) (hasClass, vendor bool, err error) { + _, _, hasClass, vendor, err = r.preflightClassMetadataDetails(ctx, dir) + return +} + +// preflightClassMetadataDetails reads only the target module metadata. It is +// intentionally separate from loadEffectiveGraph: go list -m all cannot run +// in vendor mode, and probing a legacy target must not turn that limitation +// into a runtime-provider error before a runtime project has been identified. +func (r *Resolver) preflightClassMetadataDetails(ctx context.Context, dir string) (loaded modload.Module, moduleGoMod string, hasClass, vendor bool, err error) { + goMod, err := goEnvWithPolicy(ctx, r.policy.graph, dir, "GOMOD") + 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 alternate := graphFlagValue(r.policy.graph.Flags, "modfile"); alternate != "" { + effectiveMod = alternate + } + identity, classMods, err := readTargetModFile(effectiveMod) + if err != nil { + return loaded, moduleGoMod, false, false, err + } + loaded, err = modload.LoadFrom(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 +} + +// vendorModuleCandidate is the small amount of module information needed by +// the vendor probe. It deliberately does not ask the Go command to load a +// complete module graph: that operation is not supported by vendor mode. +type vendorModuleCandidate struct { + path string + root string + mod modload.Module +} + +// probeVendorProject identifies a runtime project without invoking +// "go list -m all". A class module that has no runtime project is therefore +// still returned as ErrNotHandled and follows the legacy path. +func (r *Resolver) probeVendorProject(ctx context.Context, projectDir, moduleGoMod string, target modload.Module) (bool, error) { + if hasRuntimeProject(projectDir, target.Projects()) { + return true, nil + } + for _, candidate := range r.vendorModuleCandidates(moduleGoMod, target) { + if candidate.path == target.Path() { + continue + } + if candidate.mod.HasModfile() && hasRuntimeProject(projectDir, candidate.mod.Projects()) { + return true, nil + } + } + return false, nil +} + +// probeVendorPackage applies the same delayed runtime check to package +// targets. It first maps the package to a vendor/replacement module, then +// checks only the files in that package directory. Thus an ordinary Go +// subpackage inside a runtime module remains a legacy target. +func (r *Resolver) probeVendorPackage(ctx context.Context, moduleGoMod string, target modload.Module, importPath string) (bool, error) { + for _, candidate := range r.vendorModuleCandidates(moduleGoMod, target) { + if !moduleContainsPackage(candidate.path, importPath) { + continue + } + suffix := strings.TrimPrefix(importPath, candidate.path) + dir := filepath.Join(candidate.root, filepath.FromSlash(strings.TrimPrefix(suffix, "/"))) + canonical, err := canonicalExistingDir(dir) + if err != nil || !pathWithin(candidate.root, canonical) { + continue + } + if candidate.mod.HasModfile() && hasRuntimeProject(canonical, candidate.mod.Projects()) { + return true, nil + } + // The longest matching module owns this import path. If it has no + // runtime project, shorter module prefixes cannot change the result. + return false, nil + } + return false, nil +} + +func hasRuntimeProject(dir string, projects []*modfile.Project) bool { + if len(projects) == 0 { + return false + } + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, entry := range entries { + if entry.Type()&os.ModeSymlink != 0 { + continue + } + info, err := entry.Info() + if err != nil || !info.Mode().IsRegular() { + continue + } + ext := modfile.ClassExt(entry.Name()) + for _, project := range projects { + if project.Runtime != nil && project.IsProj(ext, entry.Name()) { + return true + } + } + } + return false +} + +func (r *Resolver) vendorModuleCandidates(moduleGoMod string, target modload.Module) []vendorModuleCandidate { + moduleRoot := filepath.Dir(moduleGoMod) + seen := make(map[string]bool) + var candidates []vendorModuleCandidate + add := func(path, root string, loaded *modload.Module) { + if path == "" || seen[path] { + return + } + root, err := canonicalExistingDir(root) + if err != nil { + return + } + var mod modload.Module + if loaded != nil { + mod = *loaded + } else { + goMod := filepath.Join(root, "go.mod") + if _, err := os.Stat(goMod); err != nil { + return + } + loadedMod, err := modload.LoadFrom(goMod, filepath.Join(root, "gox.mod")) + if err != nil { + return + } + mod = loadedMod + } + seen[path] = true + candidates = append(candidates, vendorModuleCandidate{path: path, root: root, mod: mod}) + } + add(target.Path(), moduleRoot, &target) + + local := make(map[string]string) + for _, replacement := range target.Replace { + if replacement.Old.Path == "" || replacement.New.Version != "" { + continue + } + root := replacement.New.Path + if !filepath.IsAbs(root) { + root = filepath.Join(moduleRoot, root) + } + local[replacement.Old.Path] = root + } + vendorRoots := []string{filepath.Join(moduleRoot, "vendor")} + if r.policy.graph.GoWork != "" && r.policy.graph.GoWork != "off" { + vendorRoots = append(vendorRoots, filepath.Join(filepath.Dir(r.policy.graph.GoWork), "vendor")) + } + for _, require := range target.Require { + if require.Syntax == nil || !modload.HasClassMarker(require.Syntax.Suffix) { + continue + } + path := require.Mod.Path + if path == "" { + continue + } + if root, ok := local[path]; ok { + add(path, root, nil) + continue + } + for _, vendorRoot := range vendorRoots { + add(path, filepath.Join(vendorRoot, filepath.FromSlash(path)), nil) + if seen[path] { + break + } + } + } + // Resolve the longest module prefix first, matching Go package lookup. + sort.SliceStable(candidates, func(i, j int) bool { + return len(candidates[i].path) > len(candidates[j].path) + }) + return candidates +} + +func graphHasClassMetadata(graph *effectiveGraph) (bool, error) { + loaded, err := modload.LoadFrom(graph.TargetModFile.Path, filepath.Join(graph.Target.Effective().Dir, "gox.mod")) + if err != nil { + return false, err + } + return len(graph.ClassModules) != 0 || loaded.HasProject(), nil +} + +func goEnvWithPolicy(ctx context.Context, policy GraphPolicy, dir, key string) (string, error) { + args := []string{"env", key} + cmd := commandContext(ctx, policy.GoCommand, args...) + cmd.Dir = dir + // Graph flags do not affect go env values and are deliberately never + // reconstructed into GOFLAGS; all graph operations pass them via argv. + cmd.Env = graphEnvironment(os.Environ(), policy.GoWork, nil) + out, err := cmd.Output() + if err != nil { + return "", commandError("go env "+key, err, string(cmdStderr(cmd))) + } + return strings.TrimSpace(string(out)), nil +} + +func loadResolvedClasses(graph *effectiveGraph) (*xgomod.Module, bool, error) { + target := graph.Target.Effective() + loaded, err := modload.LoadFrom(graph.TargetModFile.Path, filepath.Join(target.Dir, "gox.mod")) + if err != nil { + return nil, false, err + } + module := xgomod.New(loaded) + hasRuntime := false + if err := module.ImportClassesResolved(toXGoGraph(graph), func(info *xgomod.ProjectInfo) { + if info != nil && info.Project != nil && info.Project.Runtime != nil { + hasRuntime = true + } + }); err != nil { + return nil, false, err + } + return module, hasRuntime, 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.Runtime != nil { + projectFile = filepath.Join(dir, entry.Name()) + projectInfo = classInfo + } + } + return projectFile, projectInfo, count, nil +} + +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("runtime pack directory %q is invalid", pack.Directory) + } + if pack.IndexFile == "" || filepath.Base(pack.IndexFile) != pack.IndexFile || strings.ContainsAny(pack.IndexFile, `/\`) { + return "", "", fmt.Errorf("runtime pack index %q is invalid", pack.IndexFile) + } + root := filepath.Join(projectDir, dir) + canonical, err := canonicalExistingDir(root) + if err != nil { + return "", "", fmt.Errorf("runtime pack directory: %w", err) + } + if !pathWithin(projectDir, canonical) { + return "", "", fmt.Errorf("runtime pack directory escapes the project") + } + return filepath.ToSlash(dir), pack.IndexFile, nil +} + +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("runtime origin %q has an invalid declaring metadata snapshot", origin.Selected.Path) + } + if len(snapshot.SHA256) != sha256.Size*2 { + return fileIdentity{}, fmt.Errorf("runtime 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("runtime 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 +} + +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 effectiveVendorMode(policy GraphPolicy, moduleGoMod string, parsed *gomodfile.File) (bool, error) { + if policy.ModMode != "" { + return policy.ModMode == "vendor", nil + } + if policy.GoWork != "off" { + 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 && versionAtLeast(work.Go.Version, 1, 22) { + _, err := os.Stat(filepath.Join(filepath.Dir(policy.GoWork), "vendor", "modules.txt")) + return err == nil, nil + } + return false, nil + } + if parsed.Go == nil || !versionAtLeast(parsed.Go.Version, 1, 14) { + return false, nil + } + _, err := os.Stat(filepath.Join(filepath.Dir(moduleGoMod), "vendor", "modules.txt")) + return err == nil, 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", ErrRuntimeVendorUnsupported, mode) +} + +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/runtimeprovider/resolve_test.go b/cmd/internal/runtimeprovider/resolve_test.go new file mode 100644 index 000000000..af2787c45 --- /dev/null +++ b/cmd/internal/runtimeprovider/resolve_test.go @@ -0,0 +1,704 @@ +/* + * 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 runtimeprovider + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/goplus/mod/xgomod" + "github.com/goplus/xgo/x/xgoprojs" +) + +type runtimeFixture struct { + root string + app string + project string + framework string + mainFile string +} + +func newRuntimeFixture(t *testing.T) runtimeFixture { + 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", "provider")) + 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 +runtime v1 example.test/framework/cmd/provider +`) + mustWriteFile(t, filepath.Join(framework, "cmd", "provider", "main.go"), fakeProviderSource) + mainFile := filepath.Join(project, "main.foo") + mustWriteFile(t, mainFile, "// fake project source\n") + mustWriteFile(t, filepath.Join(project, "pack", "index.data"), "{}\n") + return runtimeFixture{root: root, app: app, project: project, framework: framework, mainFile: mainFile} +} + +func (f runtimeFixture) 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 TestResolveRuntimeTargets(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(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) { + rt, err := fixture.resolver(t).Resolve(context.Background(), target) + if err != nil { + t.Fatal(err) + } + if rt.ProjectDir != canonicalDir(t, fixture.project) || rt.ProjectFile != canonicalFile(t, fixture.mainFile) { + t.Fatalf("project identity = %q, %q", rt.ProjectDir, rt.ProjectFile) + } + if rt.ProviderPackage != "example.test/framework/cmd/provider" || rt.Protocol != "v1" { + t.Fatalf("provider = %#v", rt) + } + if rt.Origin.Selected.Path != "example.test/framework" || rt.Origin.Selected.Version != "v1.2.3" || rt.Origin.Replace == nil { + t.Fatalf("origin = %#v", rt.Origin) + } + if rt.Origin.Selected.Dir != "" || rt.Origin.Replace.Path != canonicalDir(t, fixture.framework) { + t.Fatalf("replacement identity was flattened: %#v", rt.Origin) + } + if rt.PackDir != "pack" || rt.PackIndex != "index.data" || len(rt.GoxModSHA256) != 64 { + t.Fatalf("metadata = %#v", rt) + } + }) + } +} + +func TestResolvePackageTargetKeepsCallerGraph(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(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) + rt, err := resolver.Resolve(context.Background(), &xgoprojs.PkgPathProj{Path: "example.test/framework/example"}) + if err != nil { + t.Fatal(err) + } + if rt.ModuleRoot != canonicalDir(t, fixture.framework) || rt.Graph.WorkDir != canonicalDir(t, fixture.app) { + t.Fatalf("graph roots = module %q, work %q", rt.ModuleRoot, rt.Graph.WorkDir) + } + if rt.Origin.Main || rt.Origin.Replace == nil || rt.Origin.Selected.Version != "v1.2.3" { + t.Fatalf("caller replacement graph was lost: %#v", rt.Origin) + } + if err := validateProvider(context.Background(), rt); err != nil { + t.Fatalf("provider validation switched graphs: %v", err) + } +} + +func TestResolvePackageTargetRejectsUnmarkedDependencyRuntime(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(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 runtime = %v, want ErrNotHandled", err) + } +} + +func TestResolveOrdinaryGoSubpackageInsideRuntimeModuleUsesLegacyPath(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(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 TestResolveRuntimeWithoutPack(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(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) + } + rt, err := fixture.resolver(t).Resolve(context.Background(), &xgoprojs.DirProj{Dir: fixture.project}) + if err != nil { + t.Fatal(err) + } + if rt.PackDir != "" || rt.PackIndex != "" { + t.Fatalf("pack = %q, %q", rt.PackDir, rt.PackIndex) + } + args, err := providerArgs(rt, 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/provider", 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 TestResolveRuntimeRejectsAmbiguousAndMultiFile(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(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 TestResolveRuntimeVendorFailsClosed(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(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, ErrRuntimeVendorUnsupported) { + t.Fatalf("vendor error = %v", err) + } +} + +func TestResolveVendorLeavesLegacyTargetsUnhandled(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + tests := []struct { + name string + class bool + auto bool + flags []string + }{ + {name: "main-explicit", flags: []string{"-mod=vendor"}}, + {name: "main-automatic", auto: true}, + {name: "class-explicit", class: true, flags: []string{"-mod=vendor"}}, + {name: "class-automatic", class: true, auto: 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 !errors.Is(err, ErrNotHandled) { + t.Fatalf("legacy vendor target = %v", err) + } + }) + } +} + +func TestResolveRuntimeTargetStillRejectsVendor(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\nruntime v1 example.test/framework/cmd/provider\n") + mustWriteFile(t, filepath.Join(project, "main.foo"), "// runtime 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, ErrRuntimeVendorUnsupported) { + t.Fatalf("runtime vendor target = %v", err) + } +} + +func TestResolveVendorIgnoresUnmarkedDependencyRuntime(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\nruntime v1 example.test/framework/cmd/provider\n") + mustWriteFile(t, filepath.Join(project, "main.foo"), "// unmarked dependency runtime 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 runtime = %v, want ErrNotHandled", err) + } +} + +func TestResolveNonRuntimeNotHandled(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 TestResolverDefersAmbientPolicyFailureForLegacy(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") + resolver, err := NewResolver(context.Background(), dir, nil) + 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 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 TestRuntimeRunAndBuild(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(t) + resolver := fixture.resolver(t, "-trimpath=true", "-buildvcs=false") + rt, err := resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: fixture.project}) + if err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + status, err := resolver.Run(context.Background(), rt, []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(), rt, 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 TestRuntimeBuildFailurePreservesOutput(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(t) + resolver := fixture.resolver(t) + rt, err := resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: fixture.project}) + if err != nil { + t.Fatal(err) + } + 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_PROVIDER_EXIT", "42") + status, _, err := resolver.Build(context.Background(), rt, 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 TestRuntimeBuildCancellationPreservesOutput(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(t) + resolver := fixture.resolver(t) + rt, err := resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: fixture.project}) + if err != nil { + t.Fatal(err) + } + 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, "provider-started") + t.Setenv("FAKE_PROVIDER_MARKER", marker) + t.Setenv("FAKE_PROVIDER_BLOCK", "1") + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + _, _, _ = resolver.Build(ctx, rt, 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("runtime provider did not start") + } + time.Sleep(10 * time.Millisecond) + } + cancel() + select { + case <-done: + case <-time.After(15 * time.Second): + t.Fatal("canceled runtime provider 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-runtime-output-*")) + if err != nil { + t.Fatal(err) + } + if len(matches) != 0 { + t.Fatalf("runtime output work directories remain: %v", matches) + } +} + +func TestRuntimeInstallUsesEffectiveGOBIN(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(t) + resolver := fixture.resolver(t) + rt, err := resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: fixture.project}) + if err != nil { + t.Fatal(err) + } + bin := filepath.Join(fixture.root, "custom-bin") + t.Setenv("GOBIN", bin) + status, final, err := resolver.Install(context.Background(), rt, 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 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{"1.8.1", "(devel)", "runtime-provider capability 1.8.0"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("checkRequiredXGo() error = %q, want %q", err, want) + } + } +} + +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 +} + +const fakeProviderSource = `package main + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "time" +) + +func main() { + if marker := os.Getenv("FAKE_PROVIDER_MARKER"); marker != "" { + _ = os.WriteFile(marker, []byte("started"), 0600) + } + if value := os.Getenv("FAKE_PROVIDER_EXIT"); value != "" { + code, _ := strconv.Atoi(value) + os.Exit(code) + } + if os.Getenv("FAKE_PROVIDER_BLOCK") == "1" { + for { + time.Sleep(time.Hour) + } + } + if len(os.Args) < 3 || os.Args[1] != "xgo-runtime-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/runtimeprovider/signal_boundary_unix.go b/cmd/internal/runtimeprovider/signal_boundary_unix.go new file mode 100644 index 000000000..ee148d14b --- /dev/null +++ b/cmd/internal/runtimeprovider/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 runtimeprovider + +import ( + "context" + "fmt" + "os" + "os/signal" + "sync" + "syscall" +) + +type runtimeSignalCause struct { + signal syscall.Signal +} + +func (c runtimeSignalCause) Error() string { + return fmt.Sprintf("runtime interrupted by %s", c.signal) +} + +type runtimeSignalBoundary struct { + ctx context.Context + cancel context.CancelCauseFunc + signals chan os.Signal + done chan struct{} + wait sync.WaitGroup + mu sync.Mutex + signal syscall.Signal +} + +func beginRuntimeSignalBoundary(parent context.Context) *runtimeSignalBoundary { + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithCancelCause(parent) + b := &runtimeSignalBoundary{ + 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(runtimeSignalCause{signal: unixSignal}) + } + b.mu.Unlock() + case <-b.done: + return + } + } + }() + return b +} + +func (b *runtimeSignalBoundary) Context() context.Context { return b.ctx } + +func (b *runtimeSignalBoundary) 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/runtimeprovider/signal_boundary_unix_test.go b/cmd/internal/runtimeprovider/signal_boundary_unix_test.go new file mode 100644 index 000000000..b06f2a32b --- /dev/null +++ b/cmd/internal/runtimeprovider/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 runtimeprovider + +import ( + "context" + "os" + "syscall" + "testing" + "time" +) + +func TestRuntimeSignalBoundaryRecordsSignalAndCancelsWork(t *testing.T) { + boundary := beginRuntimeSignalBoundary(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("runtime signal boundary did not cancel work") + } + cause, ok := context.Cause(boundary.Context()).(runtimeSignalCause) + if !ok || cause.signal != syscall.SIGTERM { + t.Fatalf("context cause = %#v, want SIGTERM runtime 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/runtimeprovider/signal_boundary_windows.go b/cmd/internal/runtimeprovider/signal_boundary_windows.go new file mode 100644 index 000000000..2a2ad9169 --- /dev/null +++ b/cmd/internal/runtimeprovider/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 runtimeprovider + +import ( + "context" + "os" + "os/signal" + "sync" +) + +type runtimeSignalBoundary struct { + ctx context.Context + cancel context.CancelFunc + signals chan os.Signal + done chan struct{} + wait sync.WaitGroup + mu sync.Mutex + interrupt bool +} + +func beginRuntimeSignalBoundary(parent context.Context) *runtimeSignalBoundary { + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithCancel(parent) + b := &runtimeSignalBoundary{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 *runtimeSignalBoundary) Context() context.Context { return b.ctx } + +func (b *runtimeSignalBoundary) 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/runtimeprovider/status.go b/cmd/internal/runtimeprovider/status.go new file mode 100644 index 000000000..670ed8373 --- /dev/null +++ b/cmd/internal/runtimeprovider/status.go @@ -0,0 +1,28 @@ +/* + * 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 runtimeprovider + +import "os" + +// Exit terminates a command handler with the exact provider status. Callers +// must invoke it only after all provider/output cleanup has completed. +func Exit(status ProcessStatus) { + if status.Signaled { + exitWithSignal(status.Signal) + } + os.Exit(status.Code) +} diff --git a/cmd/internal/runtimeprovider/status_unix.go b/cmd/internal/runtimeprovider/status_unix.go new file mode 100644 index 000000000..b6b34c457 --- /dev/null +++ b/cmd/internal/runtimeprovider/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 runtimeprovider + +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/runtimeprovider/status_windows.go b/cmd/internal/runtimeprovider/status_windows.go new file mode 100644 index 000000000..8711080e7 --- /dev/null +++ b/cmd/internal/runtimeprovider/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 runtimeprovider + +import "os" + +func exitWithSignal(os.Signal) { os.Exit(1) } diff --git a/cmd/internal/runtimeprovider/types.go b/cmd/internal/runtimeprovider/types.go new file mode 100644 index 000000000..df646f5c5 --- /dev/null +++ b/cmd/internal/runtimeprovider/types.go @@ -0,0 +1,124 @@ +/* + * 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 runtimeprovider implements XGo's private runtime-provider +// dispatcher. The shared argv contract lives in mod/runtimeprotocol so XGo +// and provider implementations cannot maintain divergent codecs. +package runtimeprovider + +import ( + "errors" + "io" + "os" + + "github.com/goplus/mod/runtimeprotocol" + "github.com/goplus/mod/xgomod" +) + +const protocolV1 = runtimeprotocol.Version1 + +var ( + // ErrNotHandled means that the target is not backed by a runtime provider. + // It is the only result for which callers may use the legacy GenGo path. + ErrNotHandled = errors.New("runtime provider not configured") + + ErrRuntimeVendorUnsupported = errors.New("runtime providers do not support vendor mode") + ErrRuntimeDisabled = errors.New("runtime provider execution is disabled by XGO_RUNTIME=off") + ErrRuntimeRecursive = errors.New("recursive runtime provider invocation") + ErrRuntimeArgvTooLarge = errors.New("runtime provider argv and environment are too large") +) + +type action = runtimeprotocol.Action + +const ( + actionRun = runtimeprotocol.ActionRun + actionBuild = runtimeprotocol.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 + +// GraphPolicy is the exact module/workspace policy shared by discovery, +// validation, and provider construction. +type GraphPolicy struct { + GoCommand string + GoWork string + Flags []string + ModMode string + // WorkDir anchors all Go graph operations on both sides of the wire; + // provider execution itself still runs in ProjectDir. + WorkDir string +} + +// BuildPolicy is the runtime-safe subset of XGo/Go build flags. +type BuildPolicy struct { + Flags []string + Verbose bool + Trace bool + KeepWork bool +} + +// Runtime is the immutable discovery result passed to execution. +type Runtime struct { + TargetKind TargetKind + OriginalTarget string + TargetImportPath string + DefaultExecName string + ProjectDir string + ProjectFile string + ModuleRoot string + ProviderPackage 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 provider without using protocol files or stdin. +type Streams struct { + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +func defaultStreams() Streams { + return Streams{Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr} +} + +// 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/runtimeprovider/version.go b/cmd/internal/runtimeprovider/version.go new file mode 100644 index 000000000..3500b6402 --- /dev/null +++ b/cmd/internal/runtimeprovider/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 runtimeprovider + +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.-]+)?)`) + +// runtimeProviderCapability is the XGo capability level claimed by unversioned +// development builds. Released binaries continue to use env.Version(). +const runtimeProviderCapability = "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, describeRuntimeProviderVersion(current)) + } + currentSemver, ok := comparableRuntimeProviderVersion(current) + if !ok || semver.Compare(currentSemver, requiredSemver) < 0 { + return fmt.Errorf("runtime project requires XGo %s, but xgo build is %s", required, describeRuntimeProviderVersion(current)) + } + return nil +} + +func comparableRuntimeProviderVersion(version string) (string, bool) { + if isDevelopmentVersion(version) { + return normalizeXGoVersion(runtimeProviderCapability) + } + return comparableCurrentVersion(version) +} + +func describeRuntimeProviderVersion(version string) string { + if isDevelopmentVersion(version) { + return fmt.Sprintf("%s (runtime-provider capability %s)", version, runtimeProviderCapability) + } + 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 comparableRuntimeProviderVersion. + match := baseVersionRE.FindStringSubmatch(version) + if len(match) != 2 { + return "", false + } + return normalizeXGoVersion(match[1]) +} 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 From 112c6b0826a0f4ccd9bc592f69ea3cafd8f65572 Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Tue, 18 Aug 2026 17:03:13 +0800 Subject: [PATCH 2/7] refactor(runtimeprovider): organize policy and execution flow Group build-policy rendering and tests, place resolver entry points before internal provider helpers, and consolidate dispatch and captured Go command handling without changing behavior. --- cmd/internal/runtimeprovider/dispatch.go | 20 +- cmd/internal/runtimeprovider/flags.go | 22 ++ cmd/internal/runtimeprovider/flags_test.go | 17 ++ cmd/internal/runtimeprovider/graph.go | 7 +- cmd/internal/runtimeprovider/protocol.go | 14 -- cmd/internal/runtimeprovider/provider.go | 278 ++++++++++----------- 6 files changed, 180 insertions(+), 178 deletions(-) diff --git a/cmd/internal/runtimeprovider/dispatch.go b/cmd/internal/runtimeprovider/dispatch.go index 773894e72..516cf8664 100644 --- a/cmd/internal/runtimeprovider/dispatch.go +++ b/cmd/internal/runtimeprovider/dispatch.go @@ -52,11 +52,7 @@ func TryRun(ctx context.Context, cwd string, target xgoprojs.Proj, flags, appArg } boundary := beginRuntimeSignalBoundary(ctx) status, err := resolver.Run(boundary.Context(), rt, appArgs, streams) - status, err = boundary.Finish(status, err) - if err != nil { - return DispatchResult{}, err - } - return DispatchResult{Handled: true, Status: status}, nil + return finishDispatch(boundary, DispatchResult{}, status, err) } // TryBuild resolves and, when matched, builds one runtime target. It never @@ -75,11 +71,7 @@ func TryBuild(ctx context.Context, cwd string, target xgoprojs.Proj, flags []str } boundary := beginRuntimeSignalBoundary(ctx) status, final, err := resolver.Build(boundary.Context(), rt, output, streams) - status, err = boundary.Finish(status, err) - if err != nil { - return DispatchResult{}, err - } - return DispatchResult{Handled: true, Status: status, Output: final}, nil + return finishDispatch(boundary, DispatchResult{Output: final}, status, err) } // TryInstall resolves every target before starting a provider. A request with @@ -117,11 +109,17 @@ func TryInstall(ctx context.Context, cwd string, targets []xgoprojs.Proj, flags } boundary := beginRuntimeSignalBoundary(ctx) status, final, err := resolver.Install(boundary.Context(), runtimes[0], streams) + return finishDispatch(boundary, DispatchResult{Output: final}, status, err) +} + +func finishDispatch(boundary *runtimeSignalBoundary, result DispatchResult, status ProcessStatus, err error) (DispatchResult, error) { status, err = boundary.Finish(status, err) if err != nil { return DispatchResult{}, err } - return DispatchResult{Handled: true, Status: status, Output: final}, nil + result.Handled = true + result.Status = status + return result, nil } func newDispatchResolver(ctx context.Context, cwd string, flags []string) (*Resolver, context.Context, error) { diff --git a/cmd/internal/runtimeprovider/flags.go b/cmd/internal/runtimeprovider/flags.go index 0d2c75b55..381496391 100644 --- a/cmd/internal/runtimeprovider/flags.go +++ b/cmd/internal/runtimeprovider/flags.go @@ -30,6 +30,28 @@ type parsedFlags struct { 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, len(p.Flags)+3) + if p.Verbose { + flags = append(flags, "-v"+enabledSuffix) + } + if p.Trace { + flags = append(flags, "-x"+enabledSuffix) + } + if p.KeepWork { + flags = append(flags, "-work"+enabledSuffix) + } + return append(flags, p.Flags...) +} + // parseRuntimeFlags extracts the policy needed for discovery. Rejected build // flags are retained and reported only after a runtime project is matched, so // ordinary projects keep their existing behavior. diff --git a/cmd/internal/runtimeprovider/flags_test.go b/cmd/internal/runtimeprovider/flags_test.go index 35ea24635..83ca1b2fb 100644 --- a/cmd/internal/runtimeprovider/flags_test.go +++ b/cmd/internal/runtimeprovider/flags_test.go @@ -82,6 +82,23 @@ func TestParseRuntimeFlags(t *testing.T) { } } +func TestBuildPolicyFlagForms(t *testing.T) { + policy := BuildPolicy{ + Flags: []string{"-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 TestParseRuntimeFlagsRejected(t *testing.T) { for _, flag := range []string{"-n=true", "-tags=foo", "-buildmode=pie", "-buildvcs=true", "-trimpath=false"} { got, err := parseRuntimeFlags(t.TempDir(), "/usr/bin/go", "off", "", []string{flag}) diff --git a/cmd/internal/runtimeprovider/graph.go b/cmd/internal/runtimeprovider/graph.go index a1be188cf..d9c66a767 100644 --- a/cmd/internal/runtimeprovider/graph.go +++ b/cmd/internal/runtimeprovider/graph.go @@ -139,19 +139,16 @@ func hostGoCommand() (string, error) { } func goEnvValue(ctx context.Context, goCommand, dir string, graphFlags []string, key string) (string, error) { - args := append([]string{"env"}, key) - cmd := exec.CommandContext(ctx, goCommand, args...) + cmd := commandContext(ctx, goCommand, "env", key) cmd.Dir = dir if graphFlags == nil { cmd.Env = os.Environ() } else { cmd.Env = graphEnvironment(os.Environ(), "", graphFlags) } - var stderr bytes.Buffer - cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { - return "", commandError("go env "+key, err, stderr.String()) + return "", commandError("go env "+key, err, string(cmdStderr(cmd))) } return strings.TrimSpace(string(out)), nil } diff --git a/cmd/internal/runtimeprovider/protocol.go b/cmd/internal/runtimeprovider/protocol.go index 4f58effd5..5602135b0 100644 --- a/cmd/internal/runtimeprovider/protocol.go +++ b/cmd/internal/runtimeprovider/protocol.go @@ -79,20 +79,6 @@ func providerArgs(rt *Runtime, act action, policy BuildPolicy, output, finalOutp return runtimeprotocol.Encode(request) } -func (p BuildPolicy) protocolFlags() []string { - flags := make([]string, 0, len(p.Flags)+3) - if p.Verbose { - flags = append(flags, "-v=true") - } - if p.Trace { - flags = append(flags, "-x=true") - } - if p.KeepWork { - flags = append(flags, "-work=true") - } - return append(flags, p.Flags...) -} - 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 diff --git a/cmd/internal/runtimeprovider/provider.go b/cmd/internal/runtimeprovider/provider.go index 9fd30b7f6..a3d3f0e7c 100644 --- a/cmd/internal/runtimeprovider/provider.go +++ b/cmd/internal/runtimeprovider/provider.go @@ -44,145 +44,6 @@ type builtProvider struct { keep bool } -func (p *builtProvider) cleanup() { - if p != nil && !p.keep { - _ = os.RemoveAll(p.dir) - } -} - -func validateProvider(ctx context.Context, rt *Runtime) error { - if !moduleContainsPackage(rt.Origin.Selected.Path, rt.ProviderPackage) { - return fmt.Errorf("runtime provider package %q is outside declaring module %q", rt.ProviderPackage, rt.Origin.Selected.Path) - } - args := []string{"list", "-json"} - args = append(args, rt.Graph.Flags...) - args = append(args, rt.ProviderPackage) - cmd := exec.CommandContext(ctx, rt.Graph.GoCommand, args...) - cmd.Dir = rt.Graph.WorkDir - cmd.Env = graphEnvironment(os.Environ(), rt.Graph.GoWork, nil) - var stdout, stderr bytes.Buffer - cmd.Stdout, cmd.Stderr = &stdout, &stderr - if err := cmd.Run(); err != nil { - return commandError("validate runtime provider", err, stderr.String()) - } - var pkg goListPackage - if err := json.Unmarshal(stdout.Bytes(), &pkg); err != nil { - return fmt.Errorf("decode runtime provider package: %w", err) - } - if pkg.Error != nil && pkg.Error.Err != "" { - return fmt.Errorf("runtime provider package: %s", pkg.Error.Err) - } - if pkg.ImportPath != rt.ProviderPackage { - return fmt.Errorf("runtime provider resolved as %q, want %q", pkg.ImportPath, rt.ProviderPackage) - } - if pkg.Name != "main" { - return fmt.Errorf("runtime provider package %q is %q, want command package main", rt.ProviderPackage, pkg.Name) - } - if pkg.Module == nil { - return fmt.Errorf("runtime provider package %q has no module provenance", rt.ProviderPackage) - } - module, err := normalizeListedModule(*pkg.Module) - if err != nil { - return err - } - if !sameResolvedModule(module, rt.Origin) { - return fmt.Errorf("runtime provider package %q does not match declaring module provenance", rt.ProviderPackage) - } - providerDir, err := canonicalExistingDir(pkg.Dir) - if err != nil { - return fmt.Errorf("runtime provider directory: %w", err) - } - if !pathWithin(rt.Origin.Effective().Dir, providerDir) { - return fmt.Errorf("runtime provider directory escapes declaring module") - } - return nil -} - -func sameResolvedModule(a, b ResolvedModule) bool { - if a.Main != b.Main || a.Selected != b.Selected { - return false - } - if a.Replace == nil || b.Replace == nil { - return a.Replace == nil && b.Replace == nil - } - return *a.Replace == *b.Replace -} - -func buildProvider(ctx context.Context, rt *Runtime, policy BuildPolicy, streams Streams) (*builtProvider, error) { - if goos := os.Getenv("GOOS"); goos != "" && goos != runtime.GOOS { - return nil, fmt.Errorf("runtime providers are host-only: GOOS=%s, host=%s", goos, runtime.GOOS) - } - if goarch := os.Getenv("GOARCH"); goarch != "" && goarch != runtime.GOARCH { - return nil, fmt.Errorf("runtime providers are host-only: GOARCH=%s, host=%s", goarch, runtime.GOARCH) - } - if err := validateProvider(ctx, rt); err != nil { - return nil, err - } - dir, err := os.MkdirTemp("", "xgo-runtime-provider-") - if err != nil { - return nil, err - } - if err := os.Chmod(dir, 0700); err != nil { - _ = os.RemoveAll(dir) - return nil, err - } - provider := &builtProvider{dir: dir, path: filepath.Join(dir, executableName("provider")), keep: policy.KeepWork} - args := []string{"build"} - args = append(args, rt.Graph.Flags...) - args = append(args, policy.goBuildFlags()...) - args = append(args, "-buildmode=exe", "-o", provider.path, rt.ProviderPackage) - cmd := exec.CommandContext(ctx, rt.Graph.GoCommand, args...) - cmd.Dir = rt.Graph.WorkDir - cmd.Env = hostBuildEnvironment(os.Environ(), rt.Graph.GoWork) - cmd.Stdin, cmd.Stdout, cmd.Stderr = streams.Stdin, streams.Stdout, streams.Stderr - if policy.Trace { - fmt.Fprintln(streams.Stderr, redactCommand(rt.Graph.GoCommand, args)) - } - if err := cmd.Run(); err != nil { - provider.cleanup() - return nil, fmt.Errorf("build runtime provider %q: %w", rt.ProviderPackage, err) - } - info, err := os.Lstat(provider.path) - if err != nil { - provider.cleanup() - return nil, fmt.Errorf("runtime provider build output: %w", err) - } - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() == 0 { - provider.cleanup() - return nil, fmt.Errorf("runtime provider build did not produce a regular executable") - } - if policy.KeepWork { - fmt.Fprintf(streams.Stderr, "XGO_RUNTIME_PROVIDER_WORK=%s\n", dir) - } - return provider, nil -} - -func (p BuildPolicy) goBuildFlags() []string { - flags := make([]string, 0, len(p.Flags)+3) - if p.Verbose { - flags = append(flags, "-v") - } - if p.Trace { - flags = append(flags, "-x") - } - if p.KeepWork { - flags = append(flags, "-work") - } - return append(flags, p.Flags...) -} - -func hostBuildEnvironment(base []string, goWork string) []string { - env := graphEnvironment(base, goWork, nil) - env = replaceEnv(env, "GOOS", runtime.GOOS) - env = replaceEnv(env, "GOARCH", runtime.GOARCH) - return env -} - -func providerEnvironment(base []string, rt *Runtime) []string { - env := hostBuildEnvironment(base, rt.Graph.GoWork) - return replaceEnv(env, runtimeGuardEnv, runtimeGuard(rt.ProjectDir, rt.ProviderPackage)) -} - func (r *Resolver) Run(ctx context.Context, rt *Runtime, appArgs []string, streams Streams) (ProcessStatus, error) { policy, err := r.BuildPolicy() if err != nil { @@ -196,9 +57,6 @@ func (r *Resolver) Build(ctx context.Context, rt *Runtime, requestedOutput strin if err != nil { return ProcessStatus{}, "", err } - // execute fills streams for the provider process, but Build also reports - // kept work directories after execute returns. Normalize once here so a - // caller may omit Stderr without making the post-build report panic. streams = fillStreams(streams) final, err := resolveBuildOutput(r.cwd, requestedOutput, rt.DefaultExecName) if err != nil { @@ -235,16 +93,15 @@ func (r *Resolver) Install(ctx context.Context, rt *Runtime, streams Streams) (P } func installBin(ctx context.Context, graph GraphPolicy) (string, error) { - cmd := exec.CommandContext(ctx, graph.GoCommand, "env", "-json", "GOBIN", "GOPATH") + cmd := commandContext(ctx, graph.GoCommand, "env", "-json", "GOBIN", "GOPATH") cmd.Dir = graph.WorkDir cmd.Env = graphEnvironment(os.Environ(), graph.GoWork, nil) - var stdout, stderr bytes.Buffer - cmd.Stdout, cmd.Stderr = &stdout, &stderr - if err := cmd.Run(); err != nil { - return "", commandError("resolve install directory", err, stderr.String()) + 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.Bytes(), &values); err != nil { + if err := json.Unmarshal(stdout, &values); err != nil { return "", fmt.Errorf("decode Go install directory: %w", err) } bin := values["GOBIN"] @@ -286,6 +143,131 @@ func execute(ctx context.Context, rt *Runtime, act action, policy BuildPolicy, o return runProviderProcess(ctx, cmd) } +func buildProvider(ctx context.Context, rt *Runtime, policy BuildPolicy, streams Streams) (*builtProvider, error) { + if goos := os.Getenv("GOOS"); goos != "" && goos != runtime.GOOS { + return nil, fmt.Errorf("runtime providers are host-only: GOOS=%s, host=%s", goos, runtime.GOOS) + } + if goarch := os.Getenv("GOARCH"); goarch != "" && goarch != runtime.GOARCH { + return nil, fmt.Errorf("runtime providers are host-only: GOARCH=%s, host=%s", goarch, runtime.GOARCH) + } + if err := validateProvider(ctx, rt); err != nil { + return nil, err + } + dir, err := os.MkdirTemp("", "xgo-runtime-provider-") + if err != nil { + return nil, err + } + if err := os.Chmod(dir, 0700); err != nil { + _ = os.RemoveAll(dir) + return nil, err + } + provider := &builtProvider{dir: dir, path: filepath.Join(dir, executableName("provider")), keep: policy.KeepWork} + args := []string{"build"} + args = append(args, rt.Graph.Flags...) + args = append(args, policy.goBuildFlags()...) + args = append(args, "-buildmode=exe", "-o", provider.path, rt.ProviderPackage) + cmd := exec.CommandContext(ctx, rt.Graph.GoCommand, args...) + cmd.Dir = rt.Graph.WorkDir + cmd.Env = hostBuildEnvironment(os.Environ(), rt.Graph.GoWork) + cmd.Stdin, cmd.Stdout, cmd.Stderr = streams.Stdin, streams.Stdout, streams.Stderr + if policy.Trace { + fmt.Fprintln(streams.Stderr, redactCommand(rt.Graph.GoCommand, args)) + } + if err := cmd.Run(); err != nil { + provider.cleanup() + return nil, fmt.Errorf("build runtime provider %q: %w", rt.ProviderPackage, err) + } + info, err := os.Lstat(provider.path) + if err != nil { + provider.cleanup() + return nil, fmt.Errorf("runtime provider build output: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() == 0 { + provider.cleanup() + return nil, fmt.Errorf("runtime provider build did not produce a regular executable") + } + if policy.KeepWork { + fmt.Fprintf(streams.Stderr, "XGO_RUNTIME_PROVIDER_WORK=%s\n", dir) + } + return provider, nil +} + +func (p *builtProvider) cleanup() { + if p != nil && !p.keep { + _ = os.RemoveAll(p.dir) + } +} + +func validateProvider(ctx context.Context, rt *Runtime) error { + if !moduleContainsPackage(rt.Origin.Selected.Path, rt.ProviderPackage) { + return fmt.Errorf("runtime provider package %q is outside declaring module %q", rt.ProviderPackage, rt.Origin.Selected.Path) + } + args := []string{"list", "-json"} + args = append(args, rt.Graph.Flags...) + args = append(args, rt.ProviderPackage) + cmd := exec.CommandContext(ctx, rt.Graph.GoCommand, args...) + cmd.Dir = rt.Graph.WorkDir + cmd.Env = graphEnvironment(os.Environ(), rt.Graph.GoWork, nil) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil { + return commandError("validate runtime provider", err, stderr.String()) + } + var pkg goListPackage + if err := json.Unmarshal(stdout.Bytes(), &pkg); err != nil { + return fmt.Errorf("decode runtime provider package: %w", err) + } + if pkg.Error != nil && pkg.Error.Err != "" { + return fmt.Errorf("runtime provider package: %s", pkg.Error.Err) + } + if pkg.ImportPath != rt.ProviderPackage { + return fmt.Errorf("runtime provider resolved as %q, want %q", pkg.ImportPath, rt.ProviderPackage) + } + if pkg.Name != "main" { + return fmt.Errorf("runtime provider package %q is %q, want command package main", rt.ProviderPackage, pkg.Name) + } + if pkg.Module == nil { + return fmt.Errorf("runtime provider package %q has no module provenance", rt.ProviderPackage) + } + module, err := normalizeListedModule(*pkg.Module) + if err != nil { + return err + } + if !sameResolvedModule(module, rt.Origin) { + return fmt.Errorf("runtime provider package %q does not match declaring module provenance", rt.ProviderPackage) + } + providerDir, err := canonicalExistingDir(pkg.Dir) + if err != nil { + return fmt.Errorf("runtime provider directory: %w", err) + } + if !pathWithin(rt.Origin.Effective().Dir, providerDir) { + return fmt.Errorf("runtime provider directory escapes declaring module") + } + return nil +} + +func sameResolvedModule(a, b ResolvedModule) bool { + if a.Main != b.Main || a.Selected != b.Selected { + return false + } + if a.Replace == nil || b.Replace == nil { + return a.Replace == nil && b.Replace == nil + } + return *a.Replace == *b.Replace +} + +func hostBuildEnvironment(base []string, goWork string) []string { + env := graphEnvironment(base, goWork, nil) + env = replaceEnv(env, "GOOS", runtime.GOOS) + env = replaceEnv(env, "GOARCH", runtime.GOARCH) + return env +} + +func providerEnvironment(base []string, rt *Runtime) []string { + env := hostBuildEnvironment(base, rt.Graph.GoWork) + return replaceEnv(env, runtimeGuardEnv, runtimeGuard(rt.ProjectDir, rt.ProviderPackage)) +} + func fillStreams(streams Streams) Streams { defaults := defaultStreams() if streams.Stdin == nil { From 68ddf62e319b1a92e46772c1a48e68a2786c9968 Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Thu, 20 Aug 2026 15:49:37 +0800 Subject: [PATCH 3/7] fix(runtimeprovider): harden graph and process boundaries Preserve authoritative module graph policy across discovery and provider execution. Harden versioned target resolution, cancellation handling, and transactional output publication. --- cmd/internal/runtimeprovider/flags.go | 148 +-- cmd/internal/runtimeprovider/flags_test.go | 124 +- cmd/internal/runtimeprovider/graph.go | 629 ++++++++-- cmd/internal/runtimeprovider/graph_test.go | 216 +++- cmd/internal/runtimeprovider/output.go | 28 +- cmd/internal/runtimeprovider/output_test.go | 41 + cmd/internal/runtimeprovider/process_unix.go | 12 +- .../runtimeprovider/process_unix_test.go | 49 +- .../runtimeprovider/process_windows.go | 13 +- .../runtimeprovider/process_windows_test.go | 10 + cmd/internal/runtimeprovider/protocol.go | 2 +- cmd/internal/runtimeprovider/protocol_test.go | 5 +- cmd/internal/runtimeprovider/provider.go | 55 +- cmd/internal/runtimeprovider/resolve.go | 1073 ++++++++++++++--- cmd/internal/runtimeprovider/resolve_test.go | 861 ++++++++++++- cmd/internal/runtimeprovider/types.go | 22 +- 16 files changed, 2877 insertions(+), 411 deletions(-) diff --git a/cmd/internal/runtimeprovider/flags.go b/cmd/internal/runtimeprovider/flags.go index 381496391..16dc3d293 100644 --- a/cmd/internal/runtimeprovider/flags.go +++ b/cmd/internal/runtimeprovider/flags.go @@ -19,7 +19,6 @@ package runtimeprovider import ( "fmt" "path/filepath" - "sort" "strconv" "strings" ) @@ -39,7 +38,7 @@ func (p BuildPolicy) protocolFlags() []string { } func (p BuildPolicy) formatFlags(enabledSuffix string) []string { - flags := make([]string, 0, len(p.Flags)+3) + flags := make([]string, 0, 5) if p.Verbose { flags = append(flags, "-v"+enabledSuffix) } @@ -49,7 +48,34 @@ func (p BuildPolicy) formatFlags(enabledSuffix string) []string { if p.KeepWork { flags = append(flags, "-work"+enabledSuffix) } - return append(flags, p.Flags...) + 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...) } // parseRuntimeFlags extracts the policy needed for discovery. Rejected build @@ -61,12 +87,10 @@ func parseRuntimeFlags(projectDir, goCommand, goWork, ambient string, cli []stri return parsedFlags{}, fmt.Errorf("invalid GOFLAGS: %w", err) } all := append(ambientArgs, cli...) - graphValues := make(map[string]string, 3) - graphSeen := make(map[string]int, 3) var ret parsedFlags ret.graph.GoCommand = goCommand ret.graph.GoWork = goWork - for i, arg := range all { + for _, arg := range all { name, value, ok := splitCanonicalFlag(arg) if !ok { ret.rejected = append(ret.rejected, arg) @@ -74,8 +98,9 @@ func parseRuntimeFlags(projectDir, goCommand, goWork, ambient string, cli []stri } switch name { case "mod": - switch value { - case "mod", "readonly", "vendor": + mode := modMode(value) + switch mode { + case modModeMod, modModeReadonly, modModeVendor: default: // Keep malformed runtime-only policy deferred until a // runtime project is selected. Legacy Go/XGo commands are @@ -83,9 +108,7 @@ func parseRuntimeFlags(projectDir, goCommand, goWork, ambient string, cli []stri ret.rejected = append(ret.rejected, arg) continue } - graphValues[name] = value - graphSeen[name] = i - ret.graph.ModMode = value + ret.graph.ModMode = mode case "modfile", "overlay": if value == "" { ret.rejected = append(ret.rejected, arg) @@ -96,8 +119,14 @@ func parseRuntimeFlags(projectDir, goCommand, goWork, ambient string, cli []stri ret.rejected = append(ret.rejected, arg) continue } - graphValues[name] = path - graphSeen[name] = i + if name == "modfile" { + ret.graph.ModFile = path + } else { + ret.graph.Overlay = path + // Retain the overlay for authoritative target discovery, then + // reject it only if that target selects a runtime provider. + ret.rejected = append(ret.rejected, arg) + } case "v": v, err := strconv.ParseBool(value) if err != nil { @@ -122,44 +151,20 @@ func parseRuntimeFlags(projectDir, goCommand, goWork, ambient string, cli []stri ret.rejected = append(ret.rejected, "-"+name) continue } - ret.build.Flags = replaceBuildFlag(ret.build.Flags, name, "-trimpath=true") + ret.build.TrimPath = true case "buildvcs": if value != "false" { ret.rejected = append(ret.rejected, "-"+name) continue } - ret.build.Flags = replaceBuildFlag(ret.build.Flags, name, "-buildvcs=false") + ret.build.DisableBuildVCS = true default: ret.rejected = append(ret.rejected, "-"+name) } } - type graphFlag struct { - name string - value string - pos int - } - ordered := make([]graphFlag, 0, len(graphValues)) - for name, value := range graphValues { - ordered = append(ordered, graphFlag{name: name, value: value, pos: graphSeen[name]}) - } - sort.Slice(ordered, func(i, j int) bool { return ordered[i].pos < ordered[j].pos }) - for _, flag := range ordered { - ret.graph.Flags = append(ret.graph.Flags, "-"+flag.name+"="+flag.value) - } return ret, nil } -func replaceBuildFlag(flags []string, name, value string) []string { - prefix := "-" + name - for i, flag := range flags { - if flag == prefix || strings.HasPrefix(flag, prefix+"=") { - flags[i] = value - return flags - } - } - return append(flags, value) -} - func (p parsedFlags) validateRuntime() error { if len(p.rejected) == 0 { return nil @@ -168,10 +173,13 @@ func (p parsedFlags) validateRuntime() error { } func splitCanonicalFlag(arg string) (name, value string, ok bool) { - if !strings.HasPrefix(arg, "-") || arg == "-" || strings.HasPrefix(arg, "--") { + 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 { @@ -195,48 +203,42 @@ func canonicalFlagPath(base, path string) (string, error) { return abs, nil } -// splitQuotedFields implements the quoting accepted by GOFLAGS without -// invoking a shell. Quotes are removed and backslash quotes the next byte. +func isQuotedFieldSpace(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' +} + +// splitQuotedFields mirrors cmd/internal/quoted.Split, which is what the Go +// command uses for GOFLAGS. Quotes only delimit a field when they are its first +// byte, and quoted contents are not unescaped. func splitQuotedFields(s string) ([]string, error) { var fields []string - for i := 0; i < len(s); { - for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\r' || s[i] == '\n') { - i++ + for len(s) > 0 { + for len(s) > 0 && isQuotedFieldSpace(s[0]) { + s = s[1:] } - if i == len(s) { + if len(s) == 0 { break } - var b strings.Builder - quote := byte(0) - for i < len(s) { - c := s[i] - if quote == 0 && (c == ' ' || c == '\t' || c == '\r' || c == '\n') { - break - } - switch c { - case '\'', '"': - if quote == 0 { - quote = c - } else if quote == c { - quote = 0 - } else { - b.WriteByte(c) - } - case '\\': + 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("trailing backslash") - } - b.WriteByte(s[i]) - default: - b.WriteByte(c) } - i++ + if i >= len(s) { + return nil, fmt.Errorf("unterminated %c string", quote) + } + fields = append(fields, s[:i]) + s = s[i+1:] + continue } - if quote != 0 { - return nil, fmt.Errorf("unterminated quote") + i := 0 + for i < len(s) && !isQuotedFieldSpace(s[i]) { + i++ } - fields = append(fields, b.String()) + fields = append(fields, s[:i]) + s = s[i:] } return fields, nil } diff --git a/cmd/internal/runtimeprovider/flags_test.go b/cmd/internal/runtimeprovider/flags_test.go index 83ca1b2fb..073dbf84a 100644 --- a/cmd/internal/runtimeprovider/flags_test.go +++ b/cmd/internal/runtimeprovider/flags_test.go @@ -24,67 +24,125 @@ import ( ) func TestSplitQuotedFields(t *testing.T) { - got, err := splitQuotedFields(`-buildvcs=false '-overlay=a b.json' "-modfile=x.mod" -trimpath`) - if err != nil { - t.Fatal(err) - } - want := []string{"-buildvcs=false", "-overlay=a b.json", "-modfile=x.mod", "-trimpath"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("splitQuotedFields() = %#v, want %#v", got, want) - } - for _, in := range []string{`"unterminated`, `-x=foo\`} { + 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 TestJoinQuotedFieldsRoundTrip(t *testing.T) { - want := []string{ - "-modfile=/workspace/with space/runtime.mod", - `-overlay=C:\\work tree\\overlay.json`, - `-overlay=/tmp/a\"quoted\".json`, - } - encoded := joinQuotedFields(want) - got, err := splitQuotedFields(encoded) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("splitQuotedFields(joinQuotedFields()) = %#v, want %#v (encoded %q)", got, want, encoded) - } -} - func TestParseRuntimeFlags(t *testing.T) { dir := t.TempDir() got, err := parseRuntimeFlags(dir, "/usr/bin/go", "off", - `-buildvcs=false -mod=readonly -overlay='old overlay.json'`, + `-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{ - "-overlay=" + filepath.Join(dir, "old overlay.json"), "-mod=mod", "-modfile=" + filepath.Join(dir, "alt.mod"), } - if !reflect.DeepEqual(got.graph.Flags, wantGraph) { - t.Fatalf("graph flags = %#v, want %#v", got.graph.Flags, wantGraph) + if !reflect.DeepEqual(got.graph.goFlags(), wantGraph) { + t.Fatalf("graph flags = %#v, want %#v", got.graph.goFlags(), wantGraph) } - if got.graph.ModMode != "mod" || !got.build.Verbose || got.build.Trace || !got.build.KeepWork { + 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 want := []string{"-buildvcs=false", "-trimpath=true"}; !reflect.DeepEqual(got.build.Flags, want) { - t.Fatalf("build flags = %#v, want %#v", got.build.Flags, want) + if !got.build.DisableBuildVCS || !got.build.TrimPath { + t.Fatalf("build flags = %#v", got.build) } if err := got.validateRuntime(); err != nil { t.Fatal(err) } } +func TestParseRuntimeFlagsDefersOverlayRejection(t *testing.T) { + dir := t.TempDir() + got, err := parseRuntimeFlags(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.validateRuntime(); err == nil || !strings.Contains(err.Error(), "overlay") { + t.Fatalf("validateRuntime() = %v, want deferred overlay rejection", err) + } +} + +func TestParseRuntimeFlagsAcceptsDoubleDashForms(t *testing.T) { + dir := t.TempDir() + got, err := parseRuntimeFlags(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.validateRuntime(); err != nil { + t.Fatal(err) + } + for _, flag := range []string{"--", "---trimpath"} { + got, err := parseRuntimeFlags(dir, "/usr/bin/go", "off", "", []string{flag}) + if err != nil { + t.Fatal(err) + } + if err := got.validateRuntime(); err == nil { + t.Fatalf("invalid flag %q was accepted", flag) + } + } +} + func TestBuildPolicyFlagForms(t *testing.T) { policy := BuildPolicy{ - Flags: []string{"-trimpath=true"}, + TrimPath: true, Verbose: true, Trace: true, KeepWork: true, diff --git a/cmd/internal/runtimeprovider/graph.go b/cmd/internal/runtimeprovider/graph.go index d9c66a767..c953becb6 100644 --- a/cmd/internal/runtimeprovider/graph.go +++ b/cmd/internal/runtimeprovider/graph.go @@ -42,6 +42,348 @@ type effectiveGraph struct { Modules map[string]ResolvedModule ClassModules []ResolvedModule TargetModFile fileIdentity + files *graphFileView +} + +// graphFileView keeps the logical paths changed by -overlay. Runtime-provider +// v1 never executes against this view: it uses the map to classify project +// files, while 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) readFile(path string) ([]byte, error) { + if v == nil || !v.hasOverlay() { + return os.ReadFile(path) + } + logical := overlayPath(v.workDir, 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 v.hasReplacementAncestor(logical) { + return nil, &os.PathError{Op: "read", Path: logical, Err: os.ErrNotExist} + } + return os.ReadFile(logical) +} + +// hasReplacementAncestor reports whether a path is below an exact overlay +// entry. Both a deleted parent and a parent replaced by a regular file hide +// children. Callers must check an exact entry first, because an exact child +// replacement takes precedence over a deleted parent. +func (v *graphFileView) hasReplacementAncestor(path string) bool { + _, ok := v.replacementAncestor(path) + return ok +} + +// replacementAncestor returns the nearest exact overlay entry above path. +// Nearest-entry selection matches cmd/go's path-prefix lookup and matters for +// nested deletion/addition overlays. +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 +} + +// hasReplacementBelow reports whether a non-deleted overlay key is below path. +// A deletion does not synthesize a directory and must not hide a physical file. +func (v *graphFileView) hasReplacementBelow(path string) bool { + if v == nil || !v.hasOverlay() { + return false + } + for candidate, actual := range v.replacements { + if actual != "" && candidate != path && pathWithin(path, candidate) { + return true + } + } + return false +} + +// hasVirtualDirectory reports whether an overlay child implies path as a +// directory. The replacement destination is intentionally opaque: Go follows +// it when it reads a file, and classification must not reject symlinks, +// missing files, or directories before a runtime match is known. +func (v *graphFileView) hasVirtualDirectory(path string) bool { + if v == nil || !v.hasOverlay() { + return false + } + for logical, actual := range v.replacements { + if actual != "" && logical != path && pathWithin(path, logical) { + return true + } + } + return false +} + +func (v *graphFileView) directoryVisible(dir string) (bool, error) { + if v == nil || !v.hasOverlay() { + info, err := os.Stat(dir) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + return info.IsDir(), nil + } + logical := overlayPath(v.workDir, dir) + if _, exact := v.replacements[logical]; exact { + return false, nil + } + ancestor, hasAncestor := v.replacementAncestor(logical) + if hasAncestor && ancestor != "" { + return false, nil + } + if v.hasVirtualDirectory(logical) { + return true, nil + } + info, err := os.Stat(logical) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + return info.IsDir(), nil +} + +func (v *graphFileView) physicalEntries(dir string) ([]os.DirEntry, error) { + if v == nil || !v.hasOverlay() { + return os.ReadDir(dir) + } + logical := overlayPath(v.workDir, 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.hasVirtualDirectory(logical) { + return nil, nil + } + return entries, err +} + +// regularFileNames merges physical entries with overlay keys, but never stats +// a replacement destination. This keeps ordinary legacy overlays unchanged. +func (v *graphFileView) regularFileNames(dir string) ([]string, error) { + logicalDir := dir + if v != nil && v.hasOverlay() { + logicalDir = overlayPath(v.workDir, 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 != nil && v.hasOverlay() { + if actual, replaced := v.replacements[logical]; replaced { + files[entry.Name()] = actual != "" + continue + } + if v.hasReplacementBelow(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 != nil && 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 child directories plus the first component +// of each non-deleted overlay key. It is used only by pattern classification. +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.hasReplacementBelow(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 +} + +func (v *graphFileView) regularFileVisible(path string) (bool, error) { + if v == nil || !v.hasOverlay() { + info, err := os.Stat(path) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + return info.Mode().IsRegular(), nil + } + logical := overlayPath(v.workDir, path) + if actual, exact := v.replacements[logical]; exact { + if actual == "" { + return false, nil + } + return true, nil + } + if v.hasReplacementAncestor(logical) { + return false, nil + } + info, err := os.Stat(logical) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + return info.Mode().IsRegular(), nil } type goListModule struct { @@ -56,12 +398,22 @@ type goListModule struct { } } +type goListPackage struct { + Dir string + ImportPath string + Name string + Module *goListModule + Error *struct { + Err string + } +} + 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, nil, "GOFLAGS") + ambient, err := goEnvValue(ctx, goCommand, cwd, "GOFLAGS", false) if err != nil { return parsedFlags{}, err } @@ -69,12 +421,15 @@ func preparePolicies(ctx context.Context, cwd string, cli []string) (parsedFlags if err != nil { return parsedFlags{}, err } - policy = sanitizeGraphFlags(policy) + 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, []string{}, "GOWORK") + goWork, err := goEnvValue(ctx, goCommand, cwd, "GOWORK", true) if err != nil { return parsedFlags{}, err } @@ -91,29 +446,27 @@ func preparePolicies(ctx context.Context, cwd string, cli []string) (parsedFlags } // sanitizeGraphFlags removes graph files that do not exist from discovery. -// Their original spelling is retained as a rejected runtime flag, so a -// matched runtime still reports the policy error through BuildPolicy while a -// legacy target can continue with its normal command path. -func sanitizeGraphFlags(policy parsedFlags) parsedFlags { - flags := make([]string, 0, len(policy.graph.Flags)) - for _, flag := range policy.graph.Flags { - name := "" - if strings.HasPrefix(flag, "-modfile=") { - name = "modfile" - } else if strings.HasPrefix(flag, "-overlay=") { - name = "overlay" - } - if name != "" { - path := strings.TrimPrefix(flag, "-"+name+"=") - if _, err := os.Stat(path); err != nil { - policy.rejected = append(policy.rejected, flag) - continue - } +// They remain rejected runtime flags, so a matched runtime still reports the +// policy error through BuildPolicy while a legacy target can continue with its +// normal command path. +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) } - flags = append(flags, flag) } - policy.graph.Flags = flags - return policy + return policy, nil } func hostGoCommand() (string, error) { @@ -138,13 +491,14 @@ func hostGoCommand() (string, error) { return path, nil } -func goEnvValue(ctx context.Context, goCommand, dir string, graphFlags []string, key string) (string, error) { +func goEnvValue(ctx context.Context, goCommand, dir, key string, clearGOFLAGS bool) (string, error) { cmd := commandContext(ctx, goCommand, "env", key) cmd.Dir = dir - if graphFlags == nil { - cmd.Env = os.Environ() - } else { - cmd.Env = graphEnvironment(os.Environ(), "", graphFlags) + 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", "") } out, err := cmd.Output() if err != nil { @@ -154,12 +508,14 @@ func goEnvValue(ctx context.Context, goCommand, dir string, graphFlags []string, } func loadEffectiveGraph(ctx context.Context, projectDir string, policy GraphPolicy) (*effectiveGraph, error) { - args := []string{"list", "-m", "-json"} - args = append(args, policy.Flags...) - args = append(args, "all") + view, err := newGraphFileView(policy, projectDir) + if err != nil { + return nil, err + } + args := policy.goArgs("list", "-m", "-json", "all") cmd := exec.CommandContext(ctx, policy.GoCommand, args...) cmd.Dir = projectDir - cmd.Env = graphEnvironment(os.Environ(), policy.GoWork, nil) + cmd.Env = graphEnvironment(os.Environ(), policy.GoWork) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr @@ -184,7 +540,7 @@ func loadEffectiveGraph(ctx context.Context, projectDir string, policy GraphPoli if len(raw) == 0 { return nil, fmt.Errorf("effective module graph is empty") } - projectDir, err := canonicalExistingDir(projectDir) + projectDir, err = canonicalExistingDir(projectDir) if err != nil { return nil, err } @@ -209,10 +565,10 @@ func loadEffectiveGraph(ctx context.Context, projectDir string, policy GraphPoli return nil, fmt.Errorf("project directory %q is outside the effective module graph", projectDir) } modfilePath := ret.Target.Effective().GoMod - if alternate := graphFlagValue(policy.Flags, "modfile"); alternate != "" { - modfilePath = alternate + if policy.ModFile != "" { + modfilePath = policy.ModFile } - identity, classPaths, err := readTargetModFile(modfilePath) + identity, classPaths, err := readTargetModFileView(modfilePath, view) if err != nil { return nil, err } @@ -233,6 +589,7 @@ func loadEffectiveGraph(ctx context.Context, projectDir string, policy GraphPoli } ret.ClassModules = classModules ret.TargetModFile = identity + ret.files = view return ret, nil } @@ -252,7 +609,7 @@ func downloadGraphModule(ctx context.Context, dir string, policy GraphPolicy, re query := effective.Path + "@" + effective.Version cmd := exec.CommandContext(ctx, policy.GoCommand, "mod", "download", "-json", query) cmd.Dir = dir - cmd.Env = graphEnvironment(os.Environ(), policy.GoWork, nil) + cmd.Env = graphEnvironment(os.Environ(), policy.GoWork) var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr if err := cmd.Run(); err != nil { @@ -360,13 +717,17 @@ func canonicalModuleSource(path, dir, goMod string) (string, string, error) { } func readTargetModFile(path string) (fileIdentity, []string, error) { - canonical, err := canonicalExistingFile(path) + 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 := os.ReadFile(canonical) + data, err := view.readFile(canonical) if err != nil { - return fileIdentity{}, nil, err + return fileIdentity{}, nil, fmt.Errorf("read effective target modfile: %w", err) } parsed, err := gomodfile.Parse(canonical, data, nil) if err != nil { @@ -382,10 +743,118 @@ func readTargetModFile(path string) (fileIdentity, []string, error) { return fileIdentity{Path: canonical, SHA256: sha256Bytes(data)}, classMods, nil } -func resolvePackageDirectory(graph *effectiveGraph, importPath string) (string, ResolvedModule, error) { +// canonicalFile pins a physical file in the usual case, but keeps the logical +// path for an overlay-only file. The latter is necessary because cmd/go can +// create go.mod (or an alternate modfile) solely through -overlay. +func (v *graphFileView) canonicalFile(path string) (string, error) { + if v == nil || !v.hasOverlay() { + return canonicalExistingFile(path) + } + logical := overlayPath(v.workDir, path) + if actual, exact := v.replacements[logical]; exact { + if actual == "" { + return "", &os.PathError{Op: "stat", Path: logical, Err: os.ErrNotExist} + } + return logical, nil + } + if v.hasReplacementAncestor(logical) { + return "", &os.PathError{Op: "stat", Path: logical, Err: os.ErrNotExist} + } + return canonicalExistingFile(logical) +} + +// canonicalDir is the directory counterpart to canonicalFile. A synthetic +// directory has no filesystem inode, so its clean logical path is returned; +// this is safe for classification because provider execution is rejected for +// overlay-backed runtime targets before any path is sent over the protocol. +func (v *graphFileView) canonicalDir(path string) (string, error) { + if v == nil || !v.hasOverlay() { + return canonicalExistingDir(path) + } + logical := overlayPath(v.workDir, 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.hasVirtualDirectory(logical) { + return logical, nil + } + return canonicalExistingDir(logical) +} + +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 { + // The Go command may omit physical fields for an XGo-only package. + // Resolve that candidate from the selected graph, then ask Go which + // module root owns it so a prefix match cannot cross a nested module. + 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 == "" { + // Unmarked dependencies are not materialized during runtime discovery. + // Return their authoritative package identity so Resolve can classify + // them as legacy before attempting any runtime metadata access. + return dir, module, nil + } + if !sameResolvedModule(listed, module) { + return "", ResolvedModule{}, fmt.Errorf("package target %q does not match the effective module graph", importPath) + } + if !pathWithin(module.Effective().Dir, dir) { + return "", ResolvedModule{}, fmt.Errorf("package target %q escapes module %q", importPath, module.Selected.Path) + } + return dir, module, nil +} + +func listPackageTarget(ctx context.Context, importPath, workDir string, policy GraphPolicy) (goListPackage, error) { if importPath == "" || strings.Contains(importPath, "@") || strings.Contains(importPath, "...") { - return "", ResolvedModule{}, fmt.Errorf("runtime provider does not support package target %q", importPath) + return goListPackage{}, fmt.Errorf("runtime provider does not support package target %q", importPath) + } + args := policy.goArgs("list", "-e", "-find", "-json", importPath) + cmd := exec.CommandContext(ctx, policy.GoCommand, args...) + cmd.Dir = workDir + cmd.Env = graphEnvironment(os.Environ(), policy.GoWork) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil { + return goListPackage{}, commandError("resolve package target "+importPath, err, stderr.String()) } + dec := json.NewDecoder(&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) @@ -397,16 +866,45 @@ func resolvePackageDirectory(graph *effectiveGraph, importPath string) (string, } 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) - dir := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(suffix, "/"))) - canonical, err := canonicalExistingDir(dir) + 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 !pathWithin(root, canonical) { + if !pathWithin(root, dir) { return "", ResolvedModule{}, fmt.Errorf("package target %q escapes module %q", importPath, modulePath) } - return canonical, module, nil + // An XGo-only package can be supplied entirely by an overlay, in which + // case its logical directory has no physical cwd for `go env` to enter. + // The effective graph already established the owning module; retain the + // nested-module check for physical directories and use that graph identity + // for synthetic ones. + if graph.files != nil && graph.files.hasVirtualDirectory(dir) { + return dir, module, nil + } + ownerGoMod, err := goEnvWithPolicy(ctx, policy, dir, "GOMOD") + if err != nil { + return "", ResolvedModule{}, fmt.Errorf("resolve package target %q module ownership: %w", importPath, err) + } + if ownerGoMod == "" || ownerGoMod == os.DevNull { + return "", ResolvedModule{}, fmt.Errorf("package target %q has no module ownership", importPath) + } + ownerRoot, err := canonicalExistingDir(filepath.Dir(ownerGoMod)) + if err != nil { + return "", ResolvedModule{}, fmt.Errorf("resolve package target %q module ownership: %w", importPath, err) + } + same, err := sameFile(ownerRoot, root) + if err != nil { + return "", ResolvedModule{}, fmt.Errorf("resolve package target %q module ownership: %w", importPath, err) + } + if !same { + return "", ResolvedModule{}, fmt.Errorf("package target %q crosses a nested module boundary", importPath) + } + return dir, module, nil } return "", ResolvedModule{}, fmt.Errorf("package target %q is outside the effective module graph", importPath) } @@ -420,7 +918,7 @@ func retargetEffectiveGraph(graph *effectiveGraph, target ResolvedModule) (*effe if target.Selected.Path == graph.Target.Selected.Path { modfilePath = graph.TargetModFile.Path } - identity, classPaths, err := readTargetModFile(modfilePath) + identity, classPaths, err := readTargetModFileView(modfilePath, graph.files) if err != nil { return nil, err } @@ -440,6 +938,7 @@ func retargetEffectiveGraph(graph *effectiveGraph, target ResolvedModule) (*effe Modules: graph.Modules, ClassModules: classModules, TargetModFile: identity, + files: graph.files, }, nil } @@ -447,42 +946,16 @@ func moduleContainsPackage(modulePath, packagePath string) bool { return packagePath == modulePath || strings.HasPrefix(packagePath, modulePath+"/") } -func graphFlagValue(flags []string, name string) string { - prefix := "-" + name + "=" - for i := len(flags) - 1; i >= 0; i-- { - if strings.HasPrefix(flags[i], prefix) { - return strings.TrimPrefix(flags[i], prefix) - } - } - return "" -} - -func graphEnvironment(base []string, goWork string, graphFlags []string) []string { - env := replaceEnv(base, "GOFLAGS", joinQuotedFields(graphFlags)) +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 joinQuotedFields(fields []string) string { - var joined strings.Builder - for i, field := range fields { - if i != 0 { - joined.WriteByte(' ') - } - joined.WriteByte('"') - for j := 0; j < len(field); j++ { - if field[j] == '\\' || field[j] == '"' { - joined.WriteByte('\\') - } - joined.WriteByte(field[j]) - } - joined.WriteByte('"') - } - return joined.String() -} - func replaceEnv(env []string, key, value string) []string { prefix := key + "=" ret := make([]string, 0, len(env)+1) diff --git a/cmd/internal/runtimeprovider/graph_test.go b/cmd/internal/runtimeprovider/graph_test.go index 5d87ea940..e6b9f5546 100644 --- a/cmd/internal/runtimeprovider/graph_test.go +++ b/cmd/internal/runtimeprovider/graph_test.go @@ -18,14 +18,189 @@ package runtimeprovider import ( "context" + "encoding/json" "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") + overlay := filepath.Join(root, "overlay.json") + mustWriteFile(t, logical, "module example.test/plain\n\ngo 1.25\n") + mustWriteFile(t, replacement, "module example.test/overlay\n\ngo 1.25\n") + data, err := json.Marshal(struct { + Replace map[string]string `json:"Replace"` + }{Replace: map[string]string{logical: replacement}}) + if err != nil { + t.Fatal(err) + } + mustWriteFile(t, overlay, string(data)) + 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") + overlay := filepath.Join(root, "overlay.json") + mustWriteFile(t, actual, "overlay project\n") + data, err := json.Marshal(struct { + Replace map[string]string `json:"Replace"` + }{Replace: map[string]string{logical: actual}}) + if err != nil { + t.Fatal(err) + } + mustWriteFile(t, overlay, string(data)) + 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") + overlay := filepath.Join(root, "overlay.json") + mustMkdirAll(t, parent) + mustWriteFile(t, filepath.Join(parent, "old.foo"), "old\n") + mustWriteFile(t, actual, "new\n") + data, err := json.Marshal(struct { + Replace map[string]string `json:"Replace"` + }{Replace: map[string]string{parent: "", logical: actual}}) + if err != nil { + t.Fatal(err) + } + mustWriteFile(t, overlay, string(data)) + 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 := filepath.Join(root, "overlay.json") + data, err := json.Marshal(struct { + Replace map[string]string `json:"Replace"` + }{Replace: replacements}) + if err != nil { + t.Fatal(err) + } + mustWriteFile(t, overlay, string(data)) + 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 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") + overlay := filepath.Join(root, "overlay.json") + mustWriteFile(t, actual, "module example.test/overlay\n\ngo 1.25\n") + data, err := json.Marshal(struct { + Replace map[string]string `json:"Replace"` + }{Replace: map[string]string{logical: actual}}) + if err != nil { + t.Fatal(err) + } + mustWriteFile(t, overlay, string(data)) + 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") @@ -72,7 +247,7 @@ replace example.test/framework => ../framework if origin.Replace.Dir != framework || origin.Replace.GoMod != filepath.Join(framework, "go.mod") { t.Fatalf("replacement source = %#v", origin.Replace) } - dir, module, err := resolvePackageDirectory(graph, "example.test/framework/cmd/provider") + dir, module, err := resolvePackageDirectory(context.Background(), graph, "example.test/framework/cmd/provider", app, policy.graph) if err != nil { t.Fatal(err) } @@ -81,6 +256,45 @@ replace example.test/framework => ../framework } } +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) + mustWriteFile(t, filepath.Join(app, "go.mod"), "module example.test/app\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(nested, "go.mod"), "module example.test/nested\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(nested, "main.go"), "package main\n") + policy, err := preparePolicies(context.Background(), app, nil) + if err != nil { + t.Fatal(err) + } + graph, err := loadEffectiveGraph(context.Background(), app, policy.graph) + if err != nil { + t.Fatal(err) + } + 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") diff --git a/cmd/internal/runtimeprovider/output.go b/cmd/internal/runtimeprovider/output.go index eed3d736d..9e4d11f0d 100644 --- a/cmd/internal/runtimeprovider/output.go +++ b/cmd/internal/runtimeprovider/output.go @@ -17,6 +17,7 @@ package runtimeprovider import ( + "context" "crypto/rand" "encoding/hex" "errors" @@ -53,6 +54,9 @@ func resolveBuildOutput(cwd, requested, defaultName string) (string, error) { } 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) } @@ -182,9 +186,23 @@ func (tx *outputTransaction) abort() { } func (tx *outputTransaction) commit() error { + return tx.commitContext(context.Background()) +} + +// commitContext validates and publishes the staged output. Cancellation is +// checked immediately before the rename, which is the transaction's commit +// point; cancellation after that point cannot retract an already-published +// executable. +func (tx *outputTransaction) commitContext(ctx context.Context) error { if tx == nil || tx.closed { return fmt.Errorf("runtime 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 } @@ -257,6 +275,9 @@ func (tx *outputTransaction) commit() error { 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 { @@ -266,9 +287,10 @@ func (tx *outputTransaction) commit() error { } return fmt.Errorf("commit runtime output (final state %s): %w", state, err) } - if err := tx.checkParentPath(); err != nil { - return err - } + // Rename is the commit point. A pathname check after it is diagnostic only: + // the output is already visible and must not be reported as an uncommitted + // transaction. + _ = tx.checkParentPath() // The rename above is the commit point. Cleanup and directory syncing are // best effort from here so a successfully published output is never reported // as a failed transaction. diff --git a/cmd/internal/runtimeprovider/output_test.go b/cmd/internal/runtimeprovider/output_test.go index 602469a81..276c999b5 100644 --- a/cmd/internal/runtimeprovider/output_test.go +++ b/cmd/internal/runtimeprovider/output_test.go @@ -17,6 +17,8 @@ package runtimeprovider import ( + "context" + "errors" "os" "os/exec" "path/filepath" @@ -101,6 +103,29 @@ func TestOutputTransactionFailurePreservesFinal(t *testing.T) { } } +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 := commitOutputUnlessCanceled(ctx, tx); !errors.Is(err, context.Canceled) { + t.Fatalf("commitOutputUnlessCanceled() = %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") @@ -306,4 +331,20 @@ func TestResolveBuildOutput(t *testing.T) { 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/runtimeprovider/process_unix.go b/cmd/internal/runtimeprovider/process_unix.go index 417343427..c94aba099 100644 --- a/cmd/internal/runtimeprovider/process_unix.go +++ b/cmd/internal/runtimeprovider/process_unix.go @@ -52,7 +52,11 @@ func runProviderProcess(ctx context.Context, cmd *exec.Cmd) (ProcessStatus, erro if _, cleanupErr := stopProviderProcessGroup(pgid, syscall.SIGTERM, nil); cleanupErr != nil { return ProcessStatus{}, cleanupErr } - return processStatus(err) + status, statusErr := processStatus(err) + if statusErr != nil { + return ProcessStatus{}, statusErr + } + return statusUnlessCanceled(ctx, status) case <-ctx.Done(): } @@ -61,7 +65,11 @@ func runProviderProcess(ctx context.Context, cmd *exec.Cmd) (ProcessStatus, erro if cleanupErr != nil { return ProcessStatus{}, cleanupErr } - return processStatus(err) + status, err := processStatus(err) + if err != nil { + return ProcessStatus{}, err + } + return statusUnlessCanceled(ctx, status) } func configureProviderProcessGroup(cmd *exec.Cmd) { diff --git a/cmd/internal/runtimeprovider/process_unix_test.go b/cmd/internal/runtimeprovider/process_unix_test.go index c4438f97c..b7026d56f 100644 --- a/cmd/internal/runtimeprovider/process_unix_test.go +++ b/cmd/internal/runtimeprovider/process_unix_test.go @@ -44,26 +44,63 @@ func TestRunProviderProcessForwardsRuntimeSignal(t *testing.T) { ready := filepath.Join(dir, "ready") received := filepath.Join(dir, "received") cmd := runtimeProviderHelperCommand("handle", ready, received, strconv.Itoa(int(syscall.SIGHUP))) - ctx, cancel := context.WithCancelCause(context.Background()) - t.Cleanup(func() { cancel(nil) }) + boundary := beginRuntimeSignalBoundary(context.Background()) result := make(chan providerProcessResult, 1) go func() { - status, err := runProviderProcess(ctx, cmd) + status, err := runProviderProcess(boundary.Context(), cmd) result <- providerProcessResult{status: status, err: err} }() pid := waitForHelperPID(t, ready) t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) }) - cancel(runtimeSignalCause{signal: syscall.SIGHUP}) + boundary.signals <- syscall.SIGHUP got := waitForProviderProcessResult(t, result) - if got.err != nil || got.status.Signaled || got.status.Code != 0 { - t.Fatalf("runProviderProcess() = (%+v, %v), want graceful exit", got.status, got.err) + var cause runtimeSignalCause + if !errors.As(got.err, &cause) || cause.signal != syscall.SIGHUP { + t.Fatalf("runProviderProcess() = (%+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(runProviderProcess()) = (%+v, %v), want SIGHUP status", status, err) } if signal := waitForHelperSignal(t, received); signal != syscall.SIGHUP { t.Fatalf("provider received %v, want SIGHUP", signal) } } +func TestRunProviderProcessReturnsCancellationAfterGracefulExit(t *testing.T) { + dir := t.TempDir() + ready := filepath.Join(dir, "ready") + received := filepath.Join(dir, "received") + cmd := runtimeProviderHelperCommand("handle", ready, received, strconv.Itoa(int(syscall.SIGTERM))) + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan providerProcessResult, 1) + go func() { + status, err := runProviderProcess(ctx, cmd) + result <- providerProcessResult{status: status, err: err} + }() + pid := waitForHelperPID(t, ready) + t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) }) + + cancel() + got := waitForProviderProcessResult(t, result) + if !errors.Is(got.err, context.Canceled) { + t.Fatalf("runProviderProcess() = (%+v, %v), want context cancellation", got.status, got.err) + } + if signal := waitForHelperSignal(t, received); signal != syscall.SIGTERM { + t.Fatalf("provider 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 TestRunProviderProcessEscalatesIgnoredCancellation(t *testing.T) { dir := t.TempDir() ready := filepath.Join(dir, "ready") diff --git a/cmd/internal/runtimeprovider/process_windows.go b/cmd/internal/runtimeprovider/process_windows.go index 39602e105..bb510a042 100644 --- a/cmd/internal/runtimeprovider/process_windows.go +++ b/cmd/internal/runtimeprovider/process_windows.go @@ -72,6 +72,11 @@ func runProviderProcess(ctx context.Context, cmd *exec.Cmd) (ProcessStatus, erro abortSuspendedProvider(cmd, process) return ProcessStatus{}, err } + if err := ctx.Err(); err != nil { + terminateProviderJob(job, process) + _ = cmd.Wait() + return ProcessStatus{}, err + } if err := resumeProviderProcess(process); err != nil { terminateProviderJob(job, process) _ = cmd.Wait() @@ -90,7 +95,13 @@ func runProviderProcess(ctx context.Context, cmd *exec.Cmd) (ProcessStatus, erro err = cmd.Wait() close(done) <-watchDone - return processStatus(err) + status, err := processStatus(err) + if err != nil { + return ProcessStatus{}, err + } + // 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 statusUnlessCanceled(ctx, status) } func configureSuspendedProvider(cmd *exec.Cmd) { diff --git a/cmd/internal/runtimeprovider/process_windows_test.go b/cmd/internal/runtimeprovider/process_windows_test.go index c88f34233..1cc66548b 100644 --- a/cmd/internal/runtimeprovider/process_windows_test.go +++ b/cmd/internal/runtimeprovider/process_windows_test.go @@ -21,6 +21,7 @@ package runtimeprovider import ( "bytes" "context" + "errors" "fmt" "io" "os" @@ -91,6 +92,15 @@ func TestRunProviderProcessWindows(t *testing.T) { } } +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 { diff --git a/cmd/internal/runtimeprovider/protocol.go b/cmd/internal/runtimeprovider/protocol.go index 5602135b0..30d9ca9e1 100644 --- a/cmd/internal/runtimeprovider/protocol.go +++ b/cmd/internal/runtimeprovider/protocol.go @@ -55,7 +55,7 @@ func providerArgs(rt *Runtime, act action, policy BuildPolicy, output, finalOutp GoCommand: rt.Graph.GoCommand, WorkDir: rt.Graph.WorkDir, GoWork: rt.Graph.GoWork, - Flags: append([]string(nil), rt.Graph.Flags...), + Flags: rt.Graph.goFlags(), }, BuildFlags: policy.protocolFlags(), ApplicationArgs: append([]string(nil), appArgs...), diff --git a/cmd/internal/runtimeprovider/protocol_test.go b/cmd/internal/runtimeprovider/protocol_test.go index 0045925b6..d98d129c4 100644 --- a/cmd/internal/runtimeprovider/protocol_test.go +++ b/cmd/internal/runtimeprovider/protocol_test.go @@ -46,14 +46,15 @@ func testRuntime() *Runtime { GoCommand: "/usr/bin/go", WorkDir: "/project", GoWork: "off", - Flags: []string{"-mod=mod", "-modfile=/project/alt.mod"}, + ModMode: modModeMod, + ModFile: "/project/alt.mod", }, } } func TestProviderArgsRun(t *testing.T) { rt := testRuntime() - got, err := providerArgs(rt, actionRun, BuildPolicy{Flags: []string{"-trimpath=true"}, Verbose: true, Trace: true, KeepWork: true}, "", "", []string{"", "a b", "--"}) + got, err := providerArgs(rt, actionRun, BuildPolicy{TrimPath: true, Verbose: true, Trace: true, KeepWork: true}, "", "", []string{"", "a b", "--"}) if err != nil { t.Fatal(err) } diff --git a/cmd/internal/runtimeprovider/provider.go b/cmd/internal/runtimeprovider/provider.go index a3d3f0e7c..3a7ba562a 100644 --- a/cmd/internal/runtimeprovider/provider.go +++ b/cmd/internal/runtimeprovider/provider.go @@ -28,16 +28,6 @@ import ( "runtime" ) -type goListPackage struct { - Dir string - ImportPath string - Name string - Module *goListModule - Error *struct { - Err string - } -} - type builtProvider struct { path string dir string @@ -57,6 +47,10 @@ func (r *Resolver) Build(ctx context.Context, rt *Runtime, requestedOutput strin if err != nil { return ProcessStatus{}, "", err } + return r.buildWithPolicy(ctx, rt, requestedOutput, policy, streams) +} + +func (r *Resolver) buildWithPolicy(ctx context.Context, rt *Runtime, requestedOutput string, policy BuildPolicy, streams Streams) (ProcessStatus, string, error) { streams = fillStreams(streams) final, err := resolveBuildOutput(r.cwd, requestedOutput, rt.DefaultExecName) if err != nil { @@ -71,7 +65,7 @@ func (r *Resolver) Build(ctx context.Context, rt *Runtime, requestedOutput strin if err != nil || status.Signaled || status.Code != 0 { return status, final, err } - if err := tx.commit(); err != nil { + if err := commitOutputUnlessCanceled(ctx, tx); err != nil { return ProcessStatus{}, final, err } if policy.KeepWork { @@ -82,6 +76,10 @@ func (r *Resolver) Build(ctx context.Context, rt *Runtime, requestedOutput strin // Install builds one runtime target transactionally into the effective GOBIN. func (r *Resolver) Install(ctx context.Context, rt *Runtime, streams Streams) (ProcessStatus, string, error) { + policy, err := r.BuildPolicy() + if err != nil { + return ProcessStatus{}, "", err + } bin, err := installBin(ctx, rt.Graph) if err != nil { return ProcessStatus{}, "", err @@ -89,13 +87,13 @@ func (r *Resolver) Install(ctx context.Context, rt *Runtime, streams Streams) (P if err := os.MkdirAll(bin, 0755); err != nil { return ProcessStatus{}, "", fmt.Errorf("create install directory: %w", err) } - return r.Build(ctx, rt, filepath.Join(bin, rt.DefaultExecName), streams) + return r.buildWithPolicy(ctx, rt, filepath.Join(bin, rt.DefaultExecName), policy, streams) } func installBin(ctx context.Context, graph GraphPolicy) (string, error) { cmd := commandContext(ctx, graph.GoCommand, "env", "-json", "GOBIN", "GOPATH") cmd.Dir = graph.WorkDir - cmd.Env = graphEnvironment(os.Environ(), graph.GoWork, nil) + cmd.Env = graphEnvironment(os.Environ(), graph.GoWork) stdout, err := cmd.Output() if err != nil { return "", commandError("resolve install directory", err, string(cmdStderr(cmd))) @@ -162,8 +160,7 @@ func buildProvider(ctx context.Context, rt *Runtime, policy BuildPolicy, streams return nil, err } provider := &builtProvider{dir: dir, path: filepath.Join(dir, executableName("provider")), keep: policy.KeepWork} - args := []string{"build"} - args = append(args, rt.Graph.Flags...) + args := rt.Graph.goArgs("build") args = append(args, policy.goBuildFlags()...) args = append(args, "-buildmode=exe", "-o", provider.path, rt.ProviderPackage) cmd := exec.CommandContext(ctx, rt.Graph.GoCommand, args...) @@ -202,12 +199,10 @@ func validateProvider(ctx context.Context, rt *Runtime) error { if !moduleContainsPackage(rt.Origin.Selected.Path, rt.ProviderPackage) { return fmt.Errorf("runtime provider package %q is outside declaring module %q", rt.ProviderPackage, rt.Origin.Selected.Path) } - args := []string{"list", "-json"} - args = append(args, rt.Graph.Flags...) - args = append(args, rt.ProviderPackage) + args := rt.Graph.goArgs("list", "-json", rt.ProviderPackage) cmd := exec.CommandContext(ctx, rt.Graph.GoCommand, args...) cmd.Dir = rt.Graph.WorkDir - cmd.Env = graphEnvironment(os.Environ(), rt.Graph.GoWork, nil) + cmd.Env = graphEnvironment(os.Environ(), rt.Graph.GoWork) var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr if err := cmd.Run(); err != nil { @@ -257,7 +252,7 @@ func sameResolvedModule(a, b ResolvedModule) bool { } func hostBuildEnvironment(base []string, goWork string) []string { - env := graphEnvironment(base, goWork, nil) + env := graphEnvironment(base, goWork) env = replaceEnv(env, "GOOS", runtime.GOOS) env = replaceEnv(env, "GOARCH", runtime.GOARCH) return env @@ -296,3 +291,23 @@ func processStatus(err error) (ProcessStatus, error) { } return status, nil } + +// statusUnlessCanceled closes the race where a provider exits successfully at +// the same time its parent context is canceled. A successful build/run must not +// be reported after cancellation, because build callers may publish output +// immediately after receiving that status. +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 commitOutputUnlessCanceled(ctx context.Context, tx *outputTransaction) error { + if ctx == nil { + ctx = context.Background() + } + return tx.commitContext(ctx) +} diff --git a/cmd/internal/runtimeprovider/resolve.go b/cmd/internal/runtimeprovider/resolve.go index 2b60880bf..d7e2facd0 100644 --- a/cmd/internal/runtimeprovider/resolve.go +++ b/cmd/internal/runtimeprovider/resolve.go @@ -17,16 +17,17 @@ package runtimeprovider import ( + "bytes" "context" "crypto/sha256" "encoding/hex" + "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "regexp" - "sort" "strings" "github.com/goplus/mod/modfile" @@ -35,6 +36,7 @@ import ( "github.com/goplus/xgo/env" "github.com/goplus/xgo/x/xgoprojs" gomodfile "golang.org/x/mod/modfile" + "golang.org/x/mod/module" ) const runtimeGuardEnv = "XGO_RUNTIME_GUARD" @@ -47,44 +49,27 @@ func runtimeGuard(projectDir, providerPackage string) string { type Resolver struct { cwd string policy parsedFlags - policyErr error xgoVersion string } -// NewResolver snapshots ambient GOFLAGS/GOWORK once for an invocation. A -// policy setup error is retained for BuildPolicy instead of being returned -// immediately: discovery must be able to return ErrNotHandled for an -// ordinary legacy target before runtime-only policy errors are surfaced. +// 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 runtime 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 &Resolver{cwd: cwd, policy: policy, xgoVersion: env.Version()}, nil - } - // Use a conservative, no-ambient-policy fallback for discovery. The - // retained error is checked only when execution asks for BuildPolicy. - fallback := parsedFlags{graph: GraphPolicy{GoCommand: "go", GoWork: "off"}} - if command, commandErr := hostGoCommand(); commandErr == nil { - fallback.graph.GoCommand = command - } - if parsed, parseErr := parseRuntimeFlags(cwd, fallback.graph.GoCommand, "off", "", flags); parseErr == nil { - fallback = sanitizeGraphFlags(parsed) - if fallback.graph.GoWork == "" { - fallback.graph.GoWork = "off" - } + if err != nil { + return nil, err } - return &Resolver{cwd: cwd, policy: fallback, policyErr: err, xgoVersion: env.Version()}, nil + return &Resolver{cwd: cwd, policy: policy, xgoVersion: env.Version()}, nil } // BuildPolicy returns the validated runtime build policy. Callers must invoke // it only after Resolve matched a runtime project. func (r *Resolver) BuildPolicy() (BuildPolicy, error) { - if r.policyErr != nil { - return BuildPolicy{}, r.policyErr - } if err := r.policy.validateRuntime(); err != nil { return BuildPolicy{}, err } @@ -101,8 +86,8 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, expectedFile string multiFile bool unsupportedForm string + recursivePattern bool graph *effectiveGraph - graphVendor bool graphWorkDir string ) switch target := target.(type) { @@ -111,11 +96,20 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, candidate := target.Dir if hasRecursivePattern(candidate) { unsupportedForm = "directory pattern containing ..." + recursivePattern = true candidate = trimRecursivePattern(candidate) } var err error projectDir, err = canonicalExistingDir(candidate) if err != nil { + if (os.IsNotExist(errors.Unwrap(err)) || os.IsNotExist(err)) && r.policy.graph.Overlay != "" { + kind, projectDir, expectedFile, graph, err = r.resolveOverlayLocalTarget(ctx, candidate, recursivePattern) + if err != nil { + return nil, err + } + graphWorkDir = r.cwd + break + } if os.IsNotExist(errors.Unwrap(err)) || os.IsNotExist(err) { return nil, ErrNotHandled } @@ -139,38 +133,61 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, kind, original, targetImportPath = TargetPackage, target.Path, target.Path lookupPath := target.Path if strings.Contains(lookupPath, "@") { - unsupportedForm = "package target containing @version" - lookupPath = strings.SplitN(lookupPath, "@", 2)[0] + hasRuntime, err := r.versionedPackageHasRuntime(ctx, lookupPath) + if err != nil { + return nil, err + } + if !hasRuntime { + return nil, ErrNotHandled + } + return nil, fmt.Errorf("runtime provider v1 does not support package target containing @version") } if hasRecursivePattern(lookupPath) { unsupportedForm = "package pattern containing ..." + recursivePattern = true lookupPath = trimRecursivePattern(lookupPath) } - preflightModule, preflightGoMod, callerHasClass, vendor, err := r.preflightClassMetadataDetails(ctx, r.cwd) - if err != nil && !errors.Is(err, errNoGoModule) { - return nil, err - } - callerGraph, err := loadEffectiveGraph(ctx, r.cwd, r.policy.graph) - if err != nil { - if callerHasClass && vendor { - hasRuntime, probeErr := r.probeVendorPackage(ctx, preflightGoMod, preflightModule, lookupPath) + hasOverlay := r.policy.graph.Overlay != "" + var ( + preflightModule modload.Module + preflightGoMod string + callerHasClass bool + vendor bool + preflightErr error + ) + if !hasOverlay { + preflightModule, preflightGoMod, callerHasClass, vendor, preflightErr = r.preflightClassMetadataDetails(ctx, r.cwd) + if preflightErr != nil && !errors.Is(preflightErr, errNoGoModule) { + return nil, preflightErr + } + if vendor { + // Outside a workspace, an ordinary module with no class metadata + // cannot select a runtime provider. Avoid asking `go list -m all`, + // which is unsupported in vendor mode, and preserve its legacy path. + if !callerHasClass && r.policy.graph.GoWork == "off" { + return nil, ErrNotHandled + } + hasRuntime, probeErr := r.probeVendorPackage(ctx, preflightGoMod, preflightModule, lookupPath, recursivePattern) if probeErr != nil { return nil, probeErr } if hasRuntime { - return nil, vendorUnsupportedError(r.policy.graph.ModMode) + return nil, vendorUnsupportedError(string(r.policy.graph.ModMode)) } return nil, ErrNotHandled } - if errors.Is(err, errNoGoModule) && !callerHasClass { + } + callerGraph, err := loadEffectiveGraph(ctx, r.cwd, r.policy.graph) + if err != nil { + if errors.Is(err, errNoGoModule) && !callerHasClass && !hasOverlay { return nil, ErrNotHandled } return nil, err } var targetModule ResolvedModule - projectDir, targetModule, err = resolvePackageDirectory(callerGraph, lookupPath) + projectDir, targetModule, err = resolvePackageDirectory(ctx, callerGraph, lookupPath, r.cwd, r.policy.graph) if err != nil { - if callerHasClass { + if callerHasClass || hasOverlay { return nil, err } return nil, ErrNotHandled @@ -187,7 +204,6 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, if err != nil { return nil, err } - graphVendor = vendor graphWorkDir = r.cwd default: return nil, ErrNotHandled @@ -196,7 +212,28 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, graphPolicy.WorkDir = graphWorkDir if graph == nil { - preflightModule, preflightGoMod, hasClass, vendor, err := r.preflightClassMetadataDetails(ctx, projectDir) + // An overlay changes the Go command's effective module graph and may + // introduce class markers or runtime metadata that are absent from the + // physical tree. Classify through that same graph before the physical + // preflight; otherwise a runtime target can fall through to legacy. + if overlay := graphPolicy.Overlay; overlay != "" { + overlayGraph, graphErr := loadEffectiveGraph(ctx, projectDir, graphPolicy) + if graphErr != nil { + if errors.Is(graphErr, errNoGoModule) { + return nil, ErrNotHandled + } + return nil, graphErr + } + hasRuntime, matchErr := overlayRuntimeProjectMatch(projectDir, overlayGraph, recursivePattern) + if matchErr != nil { + return nil, matchErr + } + if !hasRuntime { + return nil, ErrNotHandled + } + return nil, unsupportedOverlayError(overlay) + } + preflightModule, _, hasClass, vendor, err := r.preflightClassMetadataDetails(ctx, projectDir) if err != nil { if errors.Is(err, errNoGoModule) { return nil, ErrNotHandled @@ -207,20 +244,30 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, return nil, ErrNotHandled } if vendor { - hasRuntime, probeErr := r.probeVendorProject(ctx, projectDir, preflightGoMod, preflightModule) + hasRuntime, probeErr := r.probeVendorProject(projectDir, preflightModule, recursivePattern) if probeErr != nil { return nil, probeErr } if !hasRuntime { return nil, ErrNotHandled } - return nil, vendorUnsupportedError(r.policy.graph.ModMode) + return nil, vendorUnsupportedError(string(r.policy.graph.ModMode)) } graph, err = loadEffectiveGraph(ctx, projectDir, r.policy.graph) if err != nil { return nil, err } } else { + if graph.files != nil && graph.files.hasOverlay() { + hasRuntime, matchErr := overlayRuntimeProjectMatch(projectDir, graph, recursivePattern) + if matchErr != nil { + return nil, matchErr + } + if !hasRuntime { + return nil, ErrNotHandled + } + return nil, unsupportedOverlayError(graphPolicy.Overlay) + } hasClass, err := graphHasClassMetadata(graph) if err != nil { return nil, err @@ -229,11 +276,18 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, return nil, ErrNotHandled } } - module, hasRuntimeMetadata, err := loadResolvedClasses(graph) + module, _, err := loadResolvedClasses(graph) if err != nil { return nil, err } - if unsupportedForm != "" && hasRuntimeMetadata { + if recursivePattern { + hasRuntime, err := patternContainsRuntimeProject(projectDir, module) + if err != nil { + return nil, err + } + if !hasRuntime { + return nil, ErrNotHandled + } return nil, fmt.Errorf("runtime provider v1 does not support %s", unsupportedForm) } projectFile, info, candidates, err := findProjectFile(projectDir, module) @@ -243,8 +297,8 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, if info == nil || info.Project.Runtime == nil { return nil, ErrNotHandled } - if graphVendor { - return nil, vendorUnsupportedError(r.policy.graph.ModMode) + if unsupportedForm != "" { + return nil, fmt.Errorf("runtime provider v1 does not support %s", unsupportedForm) } if candidates != 1 { return nil, fmt.Errorf("runtime project directory %q contains %d project files; exactly one is required", projectDir, candidates) @@ -280,8 +334,7 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, if os.Getenv("XGO_RUNTIME") == "off" { return nil, ErrRuntimeDisabled } - guard := runtimeGuard(projectDir, project.Runtime.Package) - if os.Getenv(runtimeGuardEnv) == guard { + if os.Getenv(runtimeGuardEnv) != "" { return nil, ErrRuntimeRecursive } defaultName := defaultExecutableName(kind, projectDir, targetImportPath) @@ -307,6 +360,335 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, }, nil } +// versionedPackageHasRuntime classifies the requested module version in an +// isolated graph. It must not borrow metadata from the caller's selected build +// list: the same package path may describe a legacy project in one version and +// a runtime project in another. +func (r *Resolver) versionedPackageHasRuntime(ctx context.Context, target string) (bool, error) { + importPath, query, ok := splitVersionedPackageTarget(target) + if !ok { + return false, nil + } + + probeDir, err := os.MkdirTemp("", "xgo-runtime-version-probe-") + if err != nil { + return false, fmt.Errorf("create versioned package graph probe: %w", err) + } + defer os.RemoveAll(probeDir) + probeMod := "module xgo.dev/runtimeprovider/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 { + // A failed go get remains the legacy path's diagnostic responsibility. + // XGo-only packages may not be Go packages at all, so continue with the + // structured module probe below without inferring anything from stderr. + 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/runtimeprovider/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 -m all` may omit source fields for an otherwise selected + // dependency. `go list -find` is authoritative for this package and has + // already supplied a fully materialized module record. + 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, _, err := loadResolvedClasses(graph) + if err != nil { + return false, fmt.Errorf("load versioned package %q runtime metadata: %w", target, err) + } + _, 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.Runtime != 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 +} + +// resolveOverlayLocalTarget disambiguates a local argument that does not exist +// on disk. xgoprojs parses both an overlay-only directory and an overlay-only +// file as DirProj, so classification must consult the same virtual filesystem +// as the Go command before deciding that the target belongs to the legacy path. +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 { @@ -338,14 +720,14 @@ func (r *Resolver) preflightClassMetadataDetails(ctx context.Context, dir string return loaded, "", false, false, err } effectiveMod := moduleGoMod - if alternate := graphFlagValue(r.policy.graph.Flags, "modfile"); alternate != "" { - effectiveMod = alternate + 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 = modload.LoadFrom(identity.Path, filepath.Join(filepath.Dir(moduleGoMod), "gox.mod")) + loaded, err = loadRuntimeModule(identity.Path, filepath.Join(filepath.Dir(moduleGoMod), "gox.mod")) if err != nil { return loaded, moduleGoMod, false, false, err } @@ -354,162 +736,410 @@ func (r *Resolver) preflightClassMetadataDetails(ctx context.Context, dir string return loaded, moduleGoMod, hasClass, vendor, err } -// vendorModuleCandidate is the small amount of module information needed by -// the vendor probe. It deliberately does not ask the Go command to load a -// complete module graph: that operation is not supported by vendor mode. -type vendorModuleCandidate struct { - path string - root string - mod modload.Module +// probeVendorProject identifies a runtime project without invoking +// "go list -m all". Standard Go vendor snapshots do not preserve gox.mod or +// gop.mod reliably, so an external class marker is indeterminate and must fail +// closed instead of consulting a live replacement or returning ErrNotHandled. +func (r *Resolver) probeVendorProject(projectDir string, target modload.Module, recursive bool) (bool, error) { + if classPath := externalClassModule(target); classPath != "" { + return false, r.vendorClassMetadataError(classPath) + } + return matchVendorProjects(projectDir, target.Projects(), recursive) } -// probeVendorProject identifies a runtime project without invoking -// "go list -m all". A class module that has no runtime project is therefore -// still returned as ErrNotHandled and follows the legacy path. -func (r *Resolver) probeVendorProject(ctx context.Context, projectDir, moduleGoMod string, target modload.Module) (bool, error) { - if hasRuntimeProject(projectDir, target.Projects()) { - return true, nil +// probeVendorPackage uses package-specific `go list`, which remains available +// in vendor mode, to distinguish another workspace main module from an +// unmarked dependency. Only main/workspace module metadata is authoritative; +// external class metadata is absent from standard vendor data and fails closed. +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 } - for _, candidate := range r.vendorModuleCandidates(moduleGoMod, target) { - if candidate.path == target.Path() { - continue + 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 candidate.mod.HasModfile() && hasRuntimeProject(projectDir, candidate.mod.Projects()) { - return true, nil + if r.policy.graph.GoWork != "off" { + return r.probeVendorWorkspacePackage(ctx, importPath, recursive) } + return false, nil } - return false, nil + if !pkg.Module.Main { + // An unmarked dependency cannot expand the provider 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 := loadRuntimeModule(module.Effective().GoMod, filepath.Join(root, "gox.mod")) + if err != nil { + return false, err + } + if classPath := externalClassModule(loaded); classPath != "" { + return false, r.vendorClassMetadataError(classPath) + } + return matchVendorProjects(projectDir, loaded.Projects(), recursive) } -// probeVendorPackage applies the same delayed runtime check to package -// targets. It first maps the package to a vendor/replacement module, then -// checks only the files in that package directory. Thus an ordinary Go -// subpackage inside a runtime module remains a legacy target. -func (r *Resolver) probeVendorPackage(ctx context.Context, moduleGoMod string, target modload.Module, importPath string) (bool, error) { - for _, candidate := range r.vendorModuleCandidates(moduleGoMod, target) { - if !moduleContainsPackage(candidate.path, importPath) { +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 + } + ownerGoMod, err := goEnvWithPolicy(ctx, r.policy.graph, projectDir, "GOMOD") + if err != nil { + return false, err + } + if ownerGoMod == "" || ownerGoMod == os.DevNull { + return false, nil + } + same, err := sameFile(moduleGoMod, ownerGoMod) + if err != nil { + return false, err + } + if !same { + return false, nil + } + return matchVendorProjects(projectDir, target.Projects(), recursive) +} + +type workspaceVendorMember struct { + modulePath string + root string + goMod string +} + +// probeVendorWorkspacePackage handles XGo-only packages for which go list +// cannot report physical package fields. Only go.work use members are eligible: +// workspace replacements and other live dependency sources are deliberately +// excluded from this conservative vendor-mode classification. +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 } - suffix := strings.TrimPrefix(importPath, candidate.path) - dir := filepath.Join(candidate.root, filepath.FromSlash(strings.TrimPrefix(suffix, "/"))) - canonical, err := canonicalExistingDir(dir) - if err != nil || !pathWithin(candidate.root, canonical) { - continue + if selected == nil || len(member.modulePath) > len(selected.modulePath) { + selected = member } - if candidate.mod.HasModfile() && hasRuntimeProject(canonical, candidate.mod.Projects()) { - return true, nil + } + 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 := loadRuntimeModule(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 runtime 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 := goEnvWithPolicy(ctx, r.policy.graph, projectDir, "GOMOD") + 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) } - // The longest matching module owns this import path. If it has no - // runtime project, shorter module prefixes cannot change the result. - return false, nil + 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 false, nil + return members, nil +} + +// loadRuntimeModule preserves modload's gox.mod-to-gop.mod fallback while +// distinguishing an absent optional metadata file from an unreadable one. +// Runtime ownership must not fall back to the legacy path on metadata I/O +// failures. +func loadRuntimeModule(goMod, goxMod string) (modload.Module, error) { + return loadRuntimeModuleView(goMod, goxMod, nil) } -func hasRuntimeProject(dir string, projects []*modfile.Project) bool { +func loadRuntimeModuleView(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 (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 matchVendorProjects(dir string, projects []*modfile.Project, recursive bool) (bool, error) { + if recursive { + return patternContainsRuntimeProjects(dir, projects) + } + return hasRuntimeProject(dir, projects) +} + +func hasRuntimeProject(dir string, projects []*modfile.Project) (bool, error) { if len(projects) == 0 { - return false + return false, nil } entries, err := os.ReadDir(dir) if err != nil { - return false + return false, err } for _, entry := range entries { if entry.Type()&os.ModeSymlink != 0 { continue } info, err := entry.Info() - if err != nil || !info.Mode().IsRegular() { + if err != nil { + return false, err + } + if !info.Mode().IsRegular() { continue } ext := modfile.ClassExt(entry.Name()) for _, project := range projects { if project.Runtime != nil && project.IsProj(ext, entry.Name()) { - return true + return true, nil } } } - return false + return false, nil } -func (r *Resolver) vendorModuleCandidates(moduleGoMod string, target modload.Module) []vendorModuleCandidate { - moduleRoot := filepath.Dir(moduleGoMod) - seen := make(map[string]bool) - var candidates []vendorModuleCandidate - add := func(path, root string, loaded *modload.Module) { - if path == "" || seen[path] { - return - } - root, err := canonicalExistingDir(root) - if err != nil { - return - } - var mod modload.Module - if loaded != nil { - mod = *loaded - } else { - goMod := filepath.Join(root, "go.mod") - if _, err := os.Stat(goMod); err != nil { - return - } - loadedMod, err := modload.LoadFrom(goMod, filepath.Join(root, "gox.mod")) - if err != nil { - return +func graphHasClassMetadata(graph *effectiveGraph) (bool, error) { + loaded, err := loadRuntimeModuleView(graph.TargetModFile.Path, filepath.Join(graph.Target.Effective().Dir, "gox.mod"), graph.files) + if err != nil { + return false, err + } + return len(graph.ClassModules) != 0 || loaded.HasProject(), nil +} + +// overlayRuntimeProjectMatch classifies a target using the effective graph's +// overlay-aware metadata. It is intentionally a classification-only path: +// v1 has no snapshot contract for overlays, so a positive match is converted +// to an explicit unsupported error before any provider Runtime is built. +func overlayRuntimeProjectMatch(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 := overlayRuntimeProjects(graph) + if err != nil { + return false, err + } + if len(projects) == 0 { + return false, nil + } + if recursive { + return overlayWalkRuntimeProjects(projectDir, projects, graph.files) + } + names, err := graph.files.regularFileNames(projectDir) + if err != nil { + return false, err + } + for _, name := range names { + ext := modfile.ClassExt(name) + for _, project := range projects { + if project.Runtime != nil && project.IsProj(ext, name) { + return true, nil } - mod = loadedMod } - seen[path] = true - candidates = append(candidates, vendorModuleCandidate{path: path, root: root, mod: mod}) } - add(target.Path(), moduleRoot, &target) + return false, nil +} - local := make(map[string]string) - for _, replacement := range target.Replace { - if replacement.Old.Path == "" || replacement.New.Version != "" { - continue - } - root := replacement.New.Path - if !filepath.IsAbs(root) { - root = filepath.Join(moduleRoot, root) +func overlayRuntimeProjects(graph *effectiveGraph) ([]*modfile.Project, error) { + target := graph.Target.Effective() + loaded, err := loadRuntimeModuleView(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 := loadRuntimeModuleView(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) } - local[replacement.Old.Path] = root + projects = append(projects, classModule.Projects()...) } - vendorRoots := []string{filepath.Join(moduleRoot, "vendor")} - if r.policy.graph.GoWork != "" && r.policy.graph.GoWork != "off" { - vendorRoots = append(vendorRoots, filepath.Join(filepath.Dir(r.policy.graph.GoWork), "vendor")) - } - for _, require := range target.Require { - if require.Syntax == nil || !modload.HasClassMarker(require.Syntax.Suffix) { - continue + return projects, nil +} + +func overlayWalkRuntimeProjects(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 } - path := require.Mod.Path - if path == "" { - continue + visited[current] = struct{}{} + names, err := view.regularFileNames(current) + if err != nil { + return err } - if root, ok := local[path]; ok { - add(path, root, nil) - continue + for _, name := range names { + ext := modfile.ClassExt(name) + for _, project := range projects { + if project.Runtime != nil && project.IsProj(ext, name) { + return errRuntimeProjectInPattern + } + } } - for _, vendorRoot := range vendorRoots { - add(path, filepath.Join(vendorRoot, filepath.FromSlash(path)), nil) - if seen[path] { - break + 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 } - // Resolve the longest module prefix first, matching Go package lookup. - sort.SliceStable(candidates, func(i, j int) bool { - return len(candidates[i].path) > len(candidates[j].path) - }) - return candidates + err := walk(root) + if errors.Is(err, errRuntimeProjectInPattern) { + return true, nil + } + return false, err } -func graphHasClassMetadata(graph *effectiveGraph) (bool, error) { - loaded, err := modload.LoadFrom(graph.TargetModFile.Path, filepath.Join(graph.Target.Effective().Dir, "gox.mod")) - if err != nil { - return false, err - } - return len(graph.ClassModules) != 0 || loaded.HasProject(), nil +func unsupportedOverlayError(path string) error { + return fmt.Errorf("runtime provider v1 does not support flag -overlay=%s", path) } func goEnvWithPolicy(ctx context.Context, policy GraphPolicy, dir, key string) (string, error) { @@ -518,7 +1148,7 @@ func goEnvWithPolicy(ctx context.Context, policy GraphPolicy, dir, key string) ( cmd.Dir = dir // Graph flags do not affect go env values and are deliberately never // reconstructed into GOFLAGS; all graph operations pass them via argv. - cmd.Env = graphEnvironment(os.Environ(), policy.GoWork, nil) + cmd.Env = graphEnvironment(os.Environ(), policy.GoWork) out, err := cmd.Output() if err != nil { return "", commandError("go env "+key, err, string(cmdStderr(cmd))) @@ -528,7 +1158,7 @@ func goEnvWithPolicy(ctx context.Context, policy GraphPolicy, dir, key string) ( func loadResolvedClasses(graph *effectiveGraph) (*xgomod.Module, bool, error) { target := graph.Target.Effective() - loaded, err := modload.LoadFrom(graph.TargetModFile.Path, filepath.Join(target.Dir, "gox.mod")) + loaded, err := loadRuntimeModule(graph.TargetModFile.Path, filepath.Join(target.Dir, "gox.mod")) if err != nil { return nil, false, err } @@ -589,6 +1219,59 @@ func findProjectFile(dir string, module *xgomod.Module) (string, *xgomod.Project return projectFile, projectInfo, count, nil } +var errRuntimeProjectInPattern = errors.New("runtime project in pattern") + +func patternContainsRuntimeProject(root string, module *xgomod.Module) (bool, error) { + return walkRuntimePattern(root, func(dir string) (bool, error) { + _, info, _, err := findProjectFile(dir, module) + return info != nil && info.Project != nil && info.Project.Runtime != nil, err + }) +} + +func patternContainsRuntimeProjects(root string, projects []*modfile.Project) (bool, error) { + return walkRuntimePattern(root, func(dir string) (bool, error) { + return hasRuntimeProject(dir, projects) + }) +} + +func walkRuntimePattern(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 errRuntimeProjectInPattern + } + return nil + }) + if errors.Is(err, errRuntimeProjectInPattern) { + return true, nil + } + return false, err +} + func validatePack(projectDir string, pack *modfile.Pack) (string, string, error) { if pack == nil { return "", "", nil @@ -670,9 +1353,14 @@ func sameFile(a, b string) (bool, error) { func effectiveVendorMode(policy GraphPolicy, moduleGoMod string, parsed *gomodfile.File) (bool, error) { if policy.ModMode != "" { - return policy.ModMode == "vendor", nil + return policy.ModMode == modModeVendor, nil } - if policy.GoWork != "off" { + 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 @@ -681,17 +1369,64 @@ func effectiveVendorMode(policy GraphPolicy, moduleGoMod string, parsed *gomodfi if err != nil { return false, err } - if work.Go != nil && versionAtLeast(work.Go.Version, 1, 22) { - _, err := os.Stat(filepath.Join(filepath.Dir(policy.GoWork), "vendor", "modules.txt")) - return err == nil, nil + 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 modulesTextIsForWorkspace. +// A missing modules.txt retains the historical module-vendor behavior, but it +// cannot identify a workspace vendor directory. +func vendorManifestIsForWorkspace(vendorDir string) (bool, error) { + file, err := os.Open(filepath.Join(vendorDir, "modules.txt")) + if os.IsNotExist(err) { return false, nil } - if parsed.Go == nil || !versionAtLeast(parsed.Go.Version, 1, 14) { + 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 } - _, err := os.Stat(filepath.Join(filepath.Dir(moduleGoMod), "vendor", "modules.txt")) - return err == nil, 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 { diff --git a/cmd/internal/runtimeprovider/resolve_test.go b/cmd/internal/runtimeprovider/resolve_test.go index af2787c45..40decbc96 100644 --- a/cmd/internal/runtimeprovider/resolve_test.go +++ b/cmd/internal/runtimeprovider/resolve_test.go @@ -19,18 +19,183 @@ package runtimeprovider import ( "bytes" "context" + "encoding/json" "errors" + "fmt" + "net/url" "os" + "os/exec" "path/filepath" "runtime" + "sort" "strings" "testing" "time" + "github.com/goplus/mod/modfile" + "github.com/goplus/mod/modload" "github.com/goplus/mod/xgomod" "github.com/goplus/xgo/x/xgoprojs" + "golang.org/x/mod/module" + modzip "golang.org/x/mod/zip" ) +func TestResolveOverlayClassifiesRuntimeBeforePhysicalPreflight(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", "provider")) + 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 +runtime v1 example.test/framework/cmd/provider +`) + mustWriteFile(t, filepath.Join(framework, "cmd", "provider", "main.go"), fakeProviderSource) + mustWriteFile(t, filepath.Join(project, "main.foo"), "// overlaid runtime 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 := overlayRuntimeProjectMatch(canonicalProject, graph, false) + if matchErr != nil || !matched { + t.Fatalf("overlay classification = %v, %v; want runtime 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 runtime 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 runtime 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 +runtime v1 example.test/app/cmd/provider +`) + mustWriteFile(t, filepath.Join(actual, "main.go"), "package game\n") + mustWriteFile(t, filepath.Join(actual, "main.foo"), "// overlaid runtime 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) + } +} + type runtimeFixture struct { root string app string @@ -115,6 +280,27 @@ func TestResolveRuntimeTargets(t *testing.T) { } } +func TestResolveRejectsAnyNestedRuntimeDispatch(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(t) + resolver := fixture.resolver(t) + target := &xgoprojs.DirProj{Dir: fixture.project} + guards := map[string]string{ + "same provider": runtimeGuard(canonicalDir(t, fixture.project), "example.test/framework/cmd/provider"), + "different provider": runtimeGuard(canonicalDir(t, fixture.app), "example.test/other/cmd/provider"), + } + for name, guard := range guards { + t.Run(name, func(t *testing.T) { + t.Setenv(runtimeGuardEnv, guard) + if _, err := resolver.Resolve(context.Background(), target); !errors.Is(err, ErrRuntimeRecursive) { + t.Fatalf("Resolve() = %v, want ErrRuntimeRecursive", err) + } + }) + } +} + func TestResolvePackageTargetKeepsCallerGraph(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") @@ -141,6 +327,204 @@ func TestResolvePackageTargetKeepsCallerGraph(t *testing.T) { } } +func TestResolveXGoOnlyPackageTargetWithModfile(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(t) + data, err := os.ReadFile(filepath.Join(fixture.app, "go.mod")) + if err != nil { + t.Fatal(err) + } + mustWriteFile(t, filepath.Join(fixture.app, "runtime.mod"), string(data)) + resolver := fixture.resolver(t, "-modfile=runtime.mod") + if _, err := resolver.Resolve(context.Background(), &xgoprojs.PkgPathProj{Path: "example.test/app/game"}); err != nil { + t.Fatal(err) + } +} + +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\nruntime v1 example.test/framework/cmd/provider\n", + "cmd/provider/provider.go": "package provider\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": "// runtime 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 runtime project\n", + "game/main.foo": "// XGo-only subdirectory runtime 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 runtime 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 + wantRuntime bool + }{ + {name: "legacy requested version ignores runtime caller graph", callerVersion: "v1.1.0", callerClass: true, target: "example.test/app@v1.0.0"}, + {name: "runtime requested version ignores legacy caller graph", callerVersion: "v1.0.0", target: "example.test/app@v1.1.0", wantRuntime: true}, + {name: "latest query uses selected version metadata", callerVersion: "v1.0.0", target: "example.test/app@latest", wantRuntime: true}, + {name: "XGo-only runtime version is classified", callerVersion: "v1.0.0", target: "example.test/app@v1.2.0", wantRuntime: true}, + {name: "XGo-only subdirectory uses parent module", callerVersion: "v1.0.0", target: "example.test/app/game@v1.2.0", wantRuntime: true}, + {name: "nested module wins over parent prefix", callerVersion: "v1.0.0", target: "example.test/app/game@v1.3.0"}, + {name: "ordinary package in runtime 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.wantRuntime { + 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 TestLoadRuntimeModulePropagatesOptionalMetadataReadErrors(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 := loadRuntimeModule(goMod, goxMod); err == nil || !strings.Contains(err.Error(), "gox.mod") { + t.Fatalf("loadRuntimeModule() = %v, want explicit gox.mod read error", err) + } +} + +func TestLoadRuntimeModuleFallsBackToGopModWhenGoxModIsAbsent(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 := loadRuntimeModule(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 TestResolvePackageTargetRejectsUnmarkedDependencyRuntime(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") @@ -183,6 +567,36 @@ func TestResolveOrdinaryGoSubpackageInsideRuntimeModuleUsesLegacyPath(t *testing } } +func TestUnsupportedPatternOnlyRejectsMatchedRuntimeTarget(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(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 runtime 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("runtime 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 runtime 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 runtime project error = %v", err) + } +} + func TestResolveRuntimeWithoutPack(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") @@ -269,20 +683,21 @@ func TestResolveRuntimeVendorFailsClosed(t *testing.T) { } } -func TestResolveVendorLeavesLegacyTargetsUnhandled(t *testing.T) { +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 + 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"}}, - {name: "class-automatic", class: true, 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) { @@ -311,8 +726,12 @@ func TestResolveVendorLeavesLegacyTargetsUnhandled(t *testing.T) { resolver.policy.graph.ModMode = "" } _, err = resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: project}) - if !errors.Is(err, ErrNotHandled) { - t.Fatalf("legacy vendor target = %v", err) + if test.wantUnsupported { + if !errors.Is(err, ErrRuntimeVendorUnsupported) { + t.Fatalf("external class vendor target = %v, want ErrRuntimeVendorUnsupported", err) + } + } else if !errors.Is(err, ErrNotHandled) { + t.Fatalf("main-module legacy vendor target = %v, want ErrNotHandled", err) } }) } @@ -344,6 +763,331 @@ func TestResolveRuntimeTargetStillRejectsVendor(t *testing.T) { } } +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", "provider")) + 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/provider\"\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 +runtime v1 example.test/framework/cmd/provider +`) + mustWriteFile(t, filepath.Join(framework, "cmd", "provider", "provider.go"), "package provider\n") + mustWriteFile(t, filepath.Join(project, "main.foo"), "// runtime project\n") + mustRunGo(t, app, "off", "mod", "vendor") + vendoredFramework := filepath.Join(app, "vendor", "example.test", "framework") + if _, err := os.Stat(filepath.Join(vendoredFramework, "cmd", "provider", "provider.go")); err != nil { + t.Fatalf("real vendor snapshot omitted imported provider 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, ErrRuntimeVendorUnsupported) { + t.Fatalf("real vendored class target = %v, want ErrRuntimeVendorUnsupported", 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", "provider")) + 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/provider\"\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\nruntime v1 example.test/framework/cmd/provider\n") + mustWriteFile(t, filepath.Join(framework, "cmd", "provider", "provider.go"), "package provider\n") + mustWriteFile(t, filepath.Join(project, "main.foo"), "// runtime 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, ErrRuntimeVendorUnsupported) { + t.Fatalf("workspace vendored class target = %v, want ErrRuntimeVendorUnsupported", err) + } +} + +func TestResolveWorkspaceVendorPackageUsesTargetModuleMetadata(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + root := t.TempDir() + caller := filepath.Join(root, "caller") + runtimeModule := filepath.Join(root, "runtime") + runtimeProject := filepath.Join(runtimeModule, "game") + legacyModule := filepath.Join(root, "legacy") + legacyProject := filepath.Join(legacyModule, "game") + mustMkdirAll(t, caller) + mustMkdirAll(t, runtimeProject) + mustMkdirAll(t, legacyProject) + mustWriteFile(t, filepath.Join(root, "go.work"), `go 1.25 + +use ( + ./caller + ./runtime + ./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(runtimeModule, "go.mod"), "module example.test/runtime\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(runtimeModule, "gox.mod"), "xgo 1.8\nproject main.foo Game example.test/runtime\nruntime v1 example.test/runtime/cmd/provider\n") + mustWriteFile(t, filepath.Join(runtimeProject, "main.foo"), "// runtime 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/runtime/game"}); !errors.Is(err, ErrRuntimeVendorUnsupported) { + t.Fatalf("workspace runtime package in vendor mode = %v, want ErrRuntimeVendorUnsupported", 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 runtime, err := resolver.probeVendorWorkspacePackage(context.Background(), "example.test/runtime/game", false); err != nil || !runtime { + t.Fatalf("workspace fallback runtime classification = %v, %v; want true", runtime, err) + } + if runtime, err := resolver.probeVendorWorkspacePackage(context.Background(), "example.test/legacy/game", false); err != nil || runtime { + t.Fatalf("workspace fallback legacy classification = %v, %v; want false", runtime, 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, ErrRuntimeVendorUnsupported) { + t.Fatalf("workspace external class classification = %v, want ErrRuntimeVendorUnsupported", 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\nruntime v1 example.test/shared/sub/cmd/provider\n") + mustWriteFile(t, filepath.Join(childProject, "main.foo"), "// runtime 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 runtime, err := resolver.probeVendorWorkspacePackage(context.Background(), "example.test/shared/sub/game", false); err != nil || !runtime { + t.Fatalf("longest workspace member classification = %v, %v; want true", runtime, 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\nruntime v1 example.test/app/cmd/provider\n") + mustWriteFile(t, filepath.Join(app, "app.go"), "package app\n") + mustWriteFile(t, filepath.Join(project, "main.foo"), "// runtime 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, ErrRuntimeVendorUnsupported) { + t.Fatalf("recursive vendor target = %v, want ErrRuntimeVendorUnsupported", err) + } + }) + } +} + +func TestVendorRuntimeProbePropagatesFilesystemErrors(t *testing.T) { + projects := []*modfile.Project{{Runtime: &modfile.Runtime{Protocol: "v1", Package: "example.test/provider"}}} + if _, err := hasRuntimeProject(filepath.Join(t.TempDir(), "missing"), projects); err == nil { + t.Fatal("missing project directory was classified as no runtime") + } + + 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) + } +} + +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 TestResolveVendorIgnoresUnmarkedDependencyRuntime(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") @@ -397,7 +1141,7 @@ func TestResolveNonRuntimeNotHandled(t *testing.T) { } } -func TestResolverDefersAmbientPolicyFailureForLegacy(t *testing.T) { +func TestResolverRejectsAmbientPolicyFailureWithoutFallback(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } @@ -406,12 +1150,8 @@ func TestResolverDefersAmbientPolicyFailureForLegacy(t *testing.T) { mustWriteFile(t, filepath.Join(dir, "main.go"), "package main\nfunc main() {}\n") t.Setenv("GOWORK", "off") t.Setenv("GOFLAGS", "'unterminated") - resolver, err := NewResolver(context.Background(), dir, nil) - 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) + 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) } } @@ -587,6 +1327,27 @@ func TestRuntimeInstallUsesEffectiveGOBIN(t *testing.T) { } } +func TestRuntimeInstallValidatesPolicyBeforeCreatingGOBIN(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(t) + resolver := fixture.resolver(t, "-tags=unsupported") + rt, err := resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: fixture.project}) + if err != nil { + t.Fatal(err) + } + bin := filepath.Join(fixture.root, "must-not-exist", "bin") + t.Setenv("GOBIN", bin) + status, final, err := resolver.Install(context.Background(), rt, 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 TestCheckRequiredXGo(t *testing.T) { tests := []struct { required string @@ -624,6 +1385,16 @@ func TestCheckRequiredXGoReportsDevelopmentCapability(t *testing.T) { } } +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 canonicalDir(t *testing.T, path string) string { t.Helper() got, err := canonicalExistingDir(path) @@ -702,3 +1473,61 @@ func main() { } } ` + +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/runtimeprovider/types.go b/cmd/internal/runtimeprovider/types.go index df646f5c5..2f478791a 100644 --- a/cmd/internal/runtimeprovider/types.go +++ b/cmd/internal/runtimeprovider/types.go @@ -61,13 +61,22 @@ const ( 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 provider construction. type GraphPolicy struct { GoCommand string GoWork string - Flags []string - ModMode string + ModMode modMode + ModFile string + Overlay string // WorkDir anchors all Go graph operations on both sides of the wire; // provider execution itself still runs in ProjectDir. WorkDir string @@ -75,10 +84,11 @@ type GraphPolicy struct { // BuildPolicy is the runtime-safe subset of XGo/Go build flags. type BuildPolicy struct { - Flags []string - Verbose bool - Trace bool - KeepWork bool + Verbose bool + Trace bool + KeepWork bool + TrimPath bool + DisableBuildVCS bool } // Runtime is the immutable discovery result passed to execution. From 3800fc3f1f582c09260a5fbc0ddeec1a3fccd834 Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Thu, 20 Aug 2026 18:56:11 +0800 Subject: [PATCH 4/7] refactor(runtimeprovider): simplify graph and resolver boundaries --- cmd/internal/runtimeprovider/graph.go | 764 +---------- cmd/internal/runtimeprovider/graph_command.go | 183 +++ cmd/internal/runtimeprovider/graph_overlay.go | 381 +++++ cmd/internal/runtimeprovider/graph_package.go | 210 +++ cmd/internal/runtimeprovider/graph_paths.go | 71 + cmd/internal/runtimeprovider/graph_test.go | 148 +- cmd/internal/runtimeprovider/output.go | 4 - cmd/internal/runtimeprovider/output_test.go | 20 +- cmd/internal/runtimeprovider/process_unix.go | 12 +- .../runtimeprovider/process_windows.go | 6 +- cmd/internal/runtimeprovider/provider.go | 28 +- cmd/internal/runtimeprovider/provider_test.go | 169 +++ cmd/internal/runtimeprovider/resolve.go | 1220 +---------------- .../runtimeprovider/resolve_overlay.go | 207 +++ .../runtimeprovider/resolve_overlay_test.go | 184 +++ .../runtimeprovider/resolve_project.go | 303 ++++ cmd/internal/runtimeprovider/resolve_test.go | 835 +---------- .../runtimeprovider/resolve_vendor.go | 418 ++++++ .../runtimeprovider/resolve_vendor_test.go | 484 +++++++ .../runtimeprovider/resolve_versioned.go | 295 ++++ cmd/internal/runtimeprovider/types.go | 4 - 21 files changed, 3088 insertions(+), 2858 deletions(-) create mode 100644 cmd/internal/runtimeprovider/graph_command.go create mode 100644 cmd/internal/runtimeprovider/graph_overlay.go create mode 100644 cmd/internal/runtimeprovider/graph_package.go create mode 100644 cmd/internal/runtimeprovider/graph_paths.go create mode 100644 cmd/internal/runtimeprovider/resolve_overlay.go create mode 100644 cmd/internal/runtimeprovider/resolve_overlay_test.go create mode 100644 cmd/internal/runtimeprovider/resolve_project.go create mode 100644 cmd/internal/runtimeprovider/resolve_vendor.go create mode 100644 cmd/internal/runtimeprovider/resolve_vendor_test.go create mode 100644 cmd/internal/runtimeprovider/resolve_versioned.go diff --git a/cmd/internal/runtimeprovider/graph.go b/cmd/internal/runtimeprovider/graph.go index c953becb6..f79c63df4 100644 --- a/cmd/internal/runtimeprovider/graph.go +++ b/cmd/internal/runtimeprovider/graph.go @@ -23,10 +23,6 @@ import ( "errors" "fmt" "io" - "os" - "os/exec" - "path/filepath" - "sort" "strings" "github.com/goplus/mod/modload" @@ -45,347 +41,6 @@ type effectiveGraph struct { files *graphFileView } -// graphFileView keeps the logical paths changed by -overlay. Runtime-provider -// v1 never executes against this view: it uses the map to classify project -// files, while 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) readFile(path string) ([]byte, error) { - if v == nil || !v.hasOverlay() { - return os.ReadFile(path) - } - logical := overlayPath(v.workDir, 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 v.hasReplacementAncestor(logical) { - return nil, &os.PathError{Op: "read", Path: logical, Err: os.ErrNotExist} - } - return os.ReadFile(logical) -} - -// hasReplacementAncestor reports whether a path is below an exact overlay -// entry. Both a deleted parent and a parent replaced by a regular file hide -// children. Callers must check an exact entry first, because an exact child -// replacement takes precedence over a deleted parent. -func (v *graphFileView) hasReplacementAncestor(path string) bool { - _, ok := v.replacementAncestor(path) - return ok -} - -// replacementAncestor returns the nearest exact overlay entry above path. -// Nearest-entry selection matches cmd/go's path-prefix lookup and matters for -// nested deletion/addition overlays. -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 -} - -// hasReplacementBelow reports whether a non-deleted overlay key is below path. -// A deletion does not synthesize a directory and must not hide a physical file. -func (v *graphFileView) hasReplacementBelow(path string) bool { - if v == nil || !v.hasOverlay() { - return false - } - for candidate, actual := range v.replacements { - if actual != "" && candidate != path && pathWithin(path, candidate) { - return true - } - } - return false -} - -// hasVirtualDirectory reports whether an overlay child implies path as a -// directory. The replacement destination is intentionally opaque: Go follows -// it when it reads a file, and classification must not reject symlinks, -// missing files, or directories before a runtime match is known. -func (v *graphFileView) hasVirtualDirectory(path string) bool { - if v == nil || !v.hasOverlay() { - return false - } - for logical, actual := range v.replacements { - if actual != "" && logical != path && pathWithin(path, logical) { - return true - } - } - return false -} - -func (v *graphFileView) directoryVisible(dir string) (bool, error) { - if v == nil || !v.hasOverlay() { - info, err := os.Stat(dir) - if os.IsNotExist(err) { - return false, nil - } - if err != nil { - return false, err - } - return info.IsDir(), nil - } - logical := overlayPath(v.workDir, dir) - if _, exact := v.replacements[logical]; exact { - return false, nil - } - ancestor, hasAncestor := v.replacementAncestor(logical) - if hasAncestor && ancestor != "" { - return false, nil - } - if v.hasVirtualDirectory(logical) { - return true, nil - } - info, err := os.Stat(logical) - if os.IsNotExist(err) { - return false, nil - } - if err != nil { - return false, err - } - return info.IsDir(), nil -} - -func (v *graphFileView) physicalEntries(dir string) ([]os.DirEntry, error) { - if v == nil || !v.hasOverlay() { - return os.ReadDir(dir) - } - logical := overlayPath(v.workDir, 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.hasVirtualDirectory(logical) { - return nil, nil - } - return entries, err -} - -// regularFileNames merges physical entries with overlay keys, but never stats -// a replacement destination. This keeps ordinary legacy overlays unchanged. -func (v *graphFileView) regularFileNames(dir string) ([]string, error) { - logicalDir := dir - if v != nil && v.hasOverlay() { - logicalDir = overlayPath(v.workDir, 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 != nil && v.hasOverlay() { - if actual, replaced := v.replacements[logical]; replaced { - files[entry.Name()] = actual != "" - continue - } - if v.hasReplacementBelow(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 != nil && 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 child directories plus the first component -// of each non-deleted overlay key. It is used only by pattern classification. -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.hasReplacementBelow(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 -} - -func (v *graphFileView) regularFileVisible(path string) (bool, error) { - if v == nil || !v.hasOverlay() { - info, err := os.Stat(path) - if os.IsNotExist(err) { - return false, nil - } - if err != nil { - return false, err - } - return info.Mode().IsRegular(), nil - } - logical := overlayPath(v.workDir, path) - if actual, exact := v.replacements[logical]; exact { - if actual == "" { - return false, nil - } - return true, nil - } - if v.hasReplacementAncestor(logical) { - return false, nil - } - info, err := os.Stat(logical) - if os.IsNotExist(err) { - return false, nil - } - if err != nil { - return false, err - } - return info.Mode().IsRegular(), nil -} - type goListModule struct { Path string Version string @@ -398,113 +53,16 @@ type goListModule struct { } } -type goListPackage struct { - Dir string - ImportPath string - Name string - Module *goListModule - Error *struct { - Err string - } -} - -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 := parseRuntimeFlags(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 removes graph files that do not exist from discovery. -// They remain rejected runtime flags, so a matched runtime still reports the -// policy error through BuildPolicy while a legacy target can continue with its -// normal command path. -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) - } - path, err = filepath.Abs(path) - if err != nil { - return "", err - } - if real, err := filepath.EvalSymlinks(path); err == nil { - path = real - } - info, err := os.Stat(path) - if err != nil { - return "", err - } - if !info.Mode().IsRegular() { - return "", fmt.Errorf("host Go command %q is not a regular file", path) +// sameResolvedModule compares the complete selected module provenance used at +// provider and package trust boundaries. +func sameResolvedModule(a, b ResolvedModule) bool { + if a.Main != b.Main || a.Selected != b.Selected { + return false } - return path, 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", "") - } - out, err := cmd.Output() - if err != nil { - return "", commandError("go env "+key, err, string(cmdStderr(cmd))) + if a.Replace == nil || b.Replace == nil { + return a.Replace == nil && b.Replace == nil } - return strings.TrimSpace(string(out)), nil + return *a.Replace == *b.Replace } func loadEffectiveGraph(ctx context.Context, projectDir string, policy GraphPolicy) (*effectiveGraph, error) { @@ -512,22 +70,16 @@ func loadEffectiveGraph(ctx context.Context, projectDir string, policy GraphPoli if err != nil { return nil, err } - args := policy.goArgs("list", "-m", "-json", "all") - cmd := exec.CommandContext(ctx, policy.GoCommand, args...) - cmd.Dir = projectDir - cmd.Env = graphEnvironment(os.Environ(), policy.GoWork) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - message := stderr.String() + 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, commandError("go list -m", err, message) + return nil, err } var raw []goListModule - dec := json.NewDecoder(&stdout) + dec := json.NewDecoder(bytes.NewReader(stdout)) for { var module goListModule if err := dec.Decode(&module); errors.Is(err, io.EOF) { @@ -607,16 +159,12 @@ func downloadGraphModule(ctx context.Context, dir string, policy GraphPolicy, re return ResolvedModule{}, fmt.Errorf("local effective source is missing") } query := effective.Path + "@" + effective.Version - cmd := exec.CommandContext(ctx, policy.GoCommand, "mod", "download", "-json", query) - cmd.Dir = dir - cmd.Env = graphEnvironment(os.Environ(), policy.GoWork) - var stdout, stderr bytes.Buffer - cmd.Stdout, cmd.Stderr = &stdout, &stderr - if err := cmd.Run(); err != nil { - return ResolvedModule{}, commandError("go mod download "+query, err, stderr.String()) + 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.Bytes(), &downloaded); err != nil { + if err := json.Unmarshal(stdout, &downloaded); err != nil { return ResolvedModule{}, fmt.Errorf("decode downloaded module: %w", err) } if downloaded.Error != "" { @@ -742,281 +290,3 @@ func readTargetModFileView(path string, view *graphFileView) (fileIdentity, []st } return fileIdentity{Path: canonical, SHA256: sha256Bytes(data)}, classMods, nil } - -// canonicalFile pins a physical file in the usual case, but keeps the logical -// path for an overlay-only file. The latter is necessary because cmd/go can -// create go.mod (or an alternate modfile) solely through -overlay. -func (v *graphFileView) canonicalFile(path string) (string, error) { - if v == nil || !v.hasOverlay() { - return canonicalExistingFile(path) - } - logical := overlayPath(v.workDir, path) - if actual, exact := v.replacements[logical]; exact { - if actual == "" { - return "", &os.PathError{Op: "stat", Path: logical, Err: os.ErrNotExist} - } - return logical, nil - } - if v.hasReplacementAncestor(logical) { - return "", &os.PathError{Op: "stat", Path: logical, Err: os.ErrNotExist} - } - return canonicalExistingFile(logical) -} - -// canonicalDir is the directory counterpart to canonicalFile. A synthetic -// directory has no filesystem inode, so its clean logical path is returned; -// this is safe for classification because provider execution is rejected for -// overlay-backed runtime targets before any path is sent over the protocol. -func (v *graphFileView) canonicalDir(path string) (string, error) { - if v == nil || !v.hasOverlay() { - return canonicalExistingDir(path) - } - logical := overlayPath(v.workDir, 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.hasVirtualDirectory(logical) { - return logical, nil - } - return canonicalExistingDir(logical) -} - -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 { - // The Go command may omit physical fields for an XGo-only package. - // Resolve that candidate from the selected graph, then ask Go which - // module root owns it so a prefix match cannot cross a nested module. - 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 == "" { - // Unmarked dependencies are not materialized during runtime discovery. - // Return their authoritative package identity so Resolve can classify - // them as legacy before attempting any runtime metadata access. - return dir, module, nil - } - if !sameResolvedModule(listed, module) { - return "", ResolvedModule{}, fmt.Errorf("package target %q does not match the effective module graph", importPath) - } - if !pathWithin(module.Effective().Dir, dir) { - return "", ResolvedModule{}, fmt.Errorf("package target %q escapes module %q", importPath, module.Selected.Path) - } - return dir, module, nil -} - -func listPackageTarget(ctx context.Context, importPath, workDir string, policy GraphPolicy) (goListPackage, error) { - if importPath == "" || strings.Contains(importPath, "@") || strings.Contains(importPath, "...") { - return goListPackage{}, fmt.Errorf("runtime provider does not support package target %q", importPath) - } - args := policy.goArgs("list", "-e", "-find", "-json", importPath) - cmd := exec.CommandContext(ctx, policy.GoCommand, args...) - cmd.Dir = workDir - cmd.Env = graphEnvironment(os.Environ(), policy.GoWork) - var stdout, stderr bytes.Buffer - cmd.Stdout, cmd.Stderr = &stdout, &stderr - if err := cmd.Run(); err != nil { - return goListPackage{}, commandError("resolve package target "+importPath, err, stderr.String()) - } - dec := json.NewDecoder(&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 !pathWithin(root, dir) { - return "", ResolvedModule{}, fmt.Errorf("package target %q escapes module %q", importPath, modulePath) - } - // An XGo-only package can be supplied entirely by an overlay, in which - // case its logical directory has no physical cwd for `go env` to enter. - // The effective graph already established the owning module; retain the - // nested-module check for physical directories and use that graph identity - // for synthetic ones. - if graph.files != nil && graph.files.hasVirtualDirectory(dir) { - return dir, module, nil - } - ownerGoMod, err := goEnvWithPolicy(ctx, policy, dir, "GOMOD") - if err != nil { - return "", ResolvedModule{}, fmt.Errorf("resolve package target %q module ownership: %w", importPath, err) - } - if ownerGoMod == "" || ownerGoMod == os.DevNull { - return "", ResolvedModule{}, fmt.Errorf("package target %q has no module ownership", importPath) - } - ownerRoot, err := canonicalExistingDir(filepath.Dir(ownerGoMod)) - if err != nil { - return "", ResolvedModule{}, fmt.Errorf("resolve package target %q module ownership: %w", importPath, err) - } - same, err := sameFile(ownerRoot, root) - if err != nil { - return "", ResolvedModule{}, fmt.Errorf("resolve package target %q module ownership: %w", importPath, err) - } - if !same { - return "", ResolvedModule{}, fmt.Errorf("package target %q crosses a nested module boundary", importPath) - } - return dir, module, nil - } - return "", ResolvedModule{}, fmt.Errorf("package target %q is outside the effective module graph", importPath) -} - -// retargetEffectiveGraph keeps the caller's selected build list and only -// changes which already-resolved module owns the class project. This is -// essential for package targets: running go list from a dependency directory -// would silently switch to that dependency's standalone graph. -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+"/") -} - -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 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) -} - -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/runtimeprovider/graph_command.go b/cmd/internal/runtimeprovider/graph_command.go new file mode 100644 index 000000000..d1014005d --- /dev/null +++ b/cmd/internal/runtimeprovider/graph_command.go @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package runtimeprovider + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "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 := parseRuntimeFlags(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 removes graph files that do not exist from discovery. +// They remain rejected runtime flags, so a matched runtime still reports the +// policy error through BuildPolicy while a legacy target can continue with its +// normal command path. +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) + } + path, err = filepath.Abs(path) + if err != nil { + return "", err + } + if real, err := filepath.EvalSymlinks(path); err == nil { + path = real + } + info, err := os.Stat(path) + if err != nil { + return "", err + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("host Go command %q is not a regular file", path) + } + return path, 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", "") + } + out, err := cmd.Output() + if err != nil { + return "", commandError("go env "+key, err, string(cmdStderr(cmd))) + } + return strings.TrimSpace(string(out)), nil +} + +func goEnvWithPolicy(ctx context.Context, policy GraphPolicy, dir, key string) (string, error) { + cmd := graphCommand(ctx, policy, dir, "env", key) + // Graph flags do not affect go env values and are deliberately never + // reconstructed into GOFLAGS; all graph operations pass them via argv. + 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/runtimeprovider/graph_overlay.go b/cmd/internal/runtimeprovider/graph_overlay.go new file mode 100644 index 000000000..2958477b2 --- /dev/null +++ b/cmd/internal/runtimeprovider/graph_overlay.go @@ -0,0 +1,381 @@ +/* + * 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 runtimeprovider + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// graphFileView keeps the logical paths changed by -overlay. Runtime-provider +// v1 never executes against this view: it uses the map to classify project +// files, while 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 +} + +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 v.hasReplacementAncestor(logical) { + return nil, &os.PathError{Op: "read", Path: logical, Err: os.ErrNotExist} + } + return os.ReadFile(logical) +} + +// hasReplacementAncestor reports whether a path is below an exact overlay +// entry. Both a deleted parent and a parent replaced by a regular file hide +// children. Callers must check an exact entry first, because an exact child +// replacement takes precedence over a deleted parent. +func (v *graphFileView) hasReplacementAncestor(path string) bool { + _, ok := v.replacementAncestor(path) + return ok +} + +// replacementAncestor returns the nearest exact overlay entry above path. +// Nearest-entry selection matches cmd/go's path-prefix lookup and matters for +// nested deletion/addition overlays. +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 a non-deleted overlay key is below path. +// It both preserves physical files below deletions and synthesizes virtual +// directories; replacement destinations stay 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 +} + +// regularFileNames merges physical entries with overlay keys, but never stats +// a replacement destination. This keeps ordinary legacy overlays unchanged. +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 child directories plus the first component +// of each non-deleted overlay key. It is used only by pattern classification. +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 +} + +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 v.hasReplacementAncestor(logical) { + return false, nil + } + return statVisible(logical, os.FileMode.IsRegular) +} + +// canonicalFile pins a physical file in the usual case, but keeps the logical +// path for an overlay-only file. The latter is necessary because cmd/go can +// create go.mod (or an alternate modfile) solely through -overlay. +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 v.hasReplacementAncestor(logical) { + return "", &os.PathError{Op: "stat", Path: logical, Err: os.ErrNotExist} + } + return canonicalExistingFile(logical) +} + +// canonicalDir is the directory counterpart to canonicalFile. A synthetic +// directory has no filesystem inode, so its clean logical path is returned; +// this is safe for classification because provider execution is rejected for +// overlay-backed runtime targets before any path is sent over the protocol. +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/runtimeprovider/graph_package.go b/cmd/internal/runtimeprovider/graph_package.go new file mode 100644 index 000000000..358fdd953 --- /dev/null +++ b/cmd/internal/runtimeprovider/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 runtimeprovider + +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 { + // The Go command may omit physical fields for an XGo-only package. + // Resolve that candidate from the selected graph, then ask Go which + // module root owns it so a prefix match cannot cross a nested module. + 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 == "" { + // Unmarked dependencies are not materialized during runtime discovery. + // Return their authoritative package identity so Resolve can classify + // them as legacy before attempting any runtime metadata access. + return dir, module, nil + } + if !sameResolvedModule(listed, 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 := goEnvWithPolicy(ctx, policy, dir, "GOMOD") + 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 listPackageTarget(ctx context.Context, importPath, workDir string, policy GraphPolicy) (goListPackage, error) { + if importPath == "" || strings.Contains(importPath, "@") || strings.Contains(importPath, "...") { + return goListPackage{}, fmt.Errorf("runtime provider 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 + } + // An XGo-only package can be supplied entirely by an overlay, in which + // case its logical directory has no physical cwd for `go env` to enter. + // The effective graph already established the owning module; retain the + // nested-module check for physical directories and use that graph identity + // for synthetic ones. + 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's selected build list and only +// changes which already-resolved module owns the class project. This is +// essential for package targets: running go list from a dependency directory +// would silently switch to that dependency's standalone graph. +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/runtimeprovider/graph_paths.go b/cmd/internal/runtimeprovider/graph_paths.go new file mode 100644 index 000000000..90847eefd --- /dev/null +++ b/cmd/internal/runtimeprovider/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 runtimeprovider + +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/runtimeprovider/graph_test.go b/cmd/internal/runtimeprovider/graph_test.go index e6b9f5546..3b1dcb0a9 100644 --- a/cmd/internal/runtimeprovider/graph_test.go +++ b/cmd/internal/runtimeprovider/graph_test.go @@ -32,16 +32,9 @@ func TestGraphFileViewReadsOverlayReplacement(t *testing.T) { root := t.TempDir() logical := filepath.Join(root, "go.mod") replacement := filepath.Join(root, "draft.mod") - overlay := filepath.Join(root, "overlay.json") mustWriteFile(t, logical, "module example.test/plain\n\ngo 1.25\n") mustWriteFile(t, replacement, "module example.test/overlay\n\ngo 1.25\n") - data, err := json.Marshal(struct { - Replace map[string]string `json:"Replace"` - }{Replace: map[string]string{logical: replacement}}) - if err != nil { - t.Fatal(err) - } - mustWriteFile(t, overlay, string(data)) + overlay := mustOverlay(t, root, map[string]string{logical: replacement}) view, err := newGraphFileView(GraphPolicy{Overlay: overlay}, root) if err != nil { t.Fatal(err) @@ -59,15 +52,8 @@ func TestGraphFileViewSynthesizesOverlayParentDirectories(t *testing.T) { root := t.TempDir() actual := filepath.Join(root, "actual.foo") logical := filepath.Join(root, "virtual", "nested", "main.foo") - overlay := filepath.Join(root, "overlay.json") mustWriteFile(t, actual, "overlay project\n") - data, err := json.Marshal(struct { - Replace map[string]string `json:"Replace"` - }{Replace: map[string]string{logical: actual}}) - if err != nil { - t.Fatal(err) - } - mustWriteFile(t, overlay, string(data)) + overlay := mustOverlay(t, root, map[string]string{logical: actual}) view, err := newGraphFileView(GraphPolicy{Overlay: overlay}, root) if err != nil { t.Fatal(err) @@ -90,17 +76,10 @@ func TestGraphFileViewDeletedParentAllowsAddedChild(t *testing.T) { parent := filepath.Join(root, "parent") actual := filepath.Join(root, "replacement.foo") logical := filepath.Join(parent, "child", "main.foo") - overlay := filepath.Join(root, "overlay.json") mustMkdirAll(t, parent) mustWriteFile(t, filepath.Join(parent, "old.foo"), "old\n") mustWriteFile(t, actual, "new\n") - data, err := json.Marshal(struct { - Replace map[string]string `json:"Replace"` - }{Replace: map[string]string{parent: "", logical: actual}}) - if err != nil { - t.Fatal(err) - } - mustWriteFile(t, overlay, string(data)) + overlay := mustOverlay(t, root, map[string]string{parent: "", logical: actual}) view, err := newGraphFileView(GraphPolicy{Overlay: overlay}, root) if err != nil { t.Fatal(err) @@ -138,14 +117,7 @@ func TestGraphFileViewDoesNotInspectReplacementDestinations(t *testing.T) { } replacements[filepath.Join(project, "symlink.foo")] = symlink } - overlay := filepath.Join(root, "overlay.json") - data, err := json.Marshal(struct { - Replace map[string]string `json:"Replace"` - }{Replace: replacements}) - if err != nil { - t.Fatal(err) - } - mustWriteFile(t, overlay, string(data)) + overlay := mustOverlay(t, root, replacements) view, err := newGraphFileView(GraphPolicy{Overlay: overlay}, root) if err != nil { t.Fatal(err) @@ -164,6 +136,22 @@ func TestGraphFileViewDoesNotInspectReplacementDestinations(t *testing.T) { } } +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") @@ -179,15 +167,8 @@ func TestReadTargetModFileViewAllowsOverlayOnlyModfile(t *testing.T) { root := t.TempDir() logical := filepath.Join(root, "go.mod") actual := filepath.Join(root, "overlay.mod") - overlay := filepath.Join(root, "overlay.json") mustWriteFile(t, actual, "module example.test/overlay\n\ngo 1.25\n") - data, err := json.Marshal(struct { - Replace map[string]string `json:"Replace"` - }{Replace: map[string]string{logical: actual}}) - if err != nil { - t.Fatal(err) - } - mustWriteFile(t, overlay, string(data)) + overlay := mustOverlay(t, root, map[string]string{logical: actual}) view, err := newGraphFileView(GraphPolicy{Overlay: overlay}, root) if err != nil { t.Fatal(err) @@ -219,17 +200,11 @@ 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") + mustModuleFile(t, filepath.Join(framework, "go.mod"), "example.test/framework") mustWriteFile(t, filepath.Join(framework, "cmd", "provider", "main.go"), "package main\nfunc main() {}\n") - policy, err := preparePolicies(context.Background(), app, nil) - if err != nil { - t.Fatal(err) - } - graph, err := loadEffectiveGraph(context.Background(), filepath.Join(app, "game"), policy.graph) - if err != nil { - t.Fatal(err) - } + 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) } @@ -240,7 +215,7 @@ replace example.test/framework => ../framework if origin.Selected.Version != "v1.2.3" || origin.Selected.Dir != "" || origin.Replace == nil { t.Fatalf("replacement was flattened: %#v", origin) } - framework, err = canonicalExistingDir(framework) + framework, err := canonicalExistingDir(framework) if err != nil { t.Fatal(err) } @@ -264,17 +239,11 @@ func TestResolvePackageDirectoryHonorsNestedModuleBoundary(t *testing.T) { app := t.TempDir() nested := filepath.Join(app, "nested") mustMkdirAll(t, nested) - mustWriteFile(t, filepath.Join(app, "go.mod"), "module example.test/app\n\ngo 1.25\n") - mustWriteFile(t, filepath.Join(nested, "go.mod"), "module example.test/nested\n\ngo 1.25\n") + 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, err := preparePolicies(context.Background(), app, nil) - if err != nil { - t.Fatal(err) - } - graph, err := loadEffectiveGraph(context.Background(), app, policy.graph) - if err != nil { - t.Fatal(err) - } + 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") } @@ -301,16 +270,10 @@ func TestLoadEffectiveGraphModfile(t *testing.T) { } t.Setenv("GOWORK", "off") dir := t.TempDir() - mustWriteFile(t, filepath.Join(dir, "go.mod"), "module example.test/app\n\ngo 1.25\n") - mustWriteFile(t, filepath.Join(dir, "runtime.mod"), "module example.test/app\n\ngo 1.25\n") - policy, err := preparePolicies(context.Background(), dir, []string{"-modfile=runtime.mod"}) - if err != nil { - t.Fatal(err) - } - graph, err := loadEffectiveGraph(context.Background(), dir, policy.graph) - if err != nil { - t.Fatal(err) - } + mustModuleFile(t, filepath.Join(dir, "go.mod"), "example.test/app") + mustModuleFile(t, filepath.Join(dir, "runtime.mod"), "example.test/app") + policy := mustPolicies(t, dir, "-modfile=runtime.mod") + graph := mustGraph(t, dir, policy.graph) wantModfile, err := canonicalExistingFile(filepath.Join(dir, "runtime.mod")) if err != nil { t.Fatal(err) @@ -373,7 +336,7 @@ func TestRetargetEffectiveGraphPreservesClassModuleOrder(t *testing.T) { frameworkGoMod := filepath.Join(framework, "go.mod") firstGoMod := filepath.Join(firstDir, "go.mod") secondGoMod := filepath.Join(secondDir, "go.mod") - mustWriteFile(t, appGoMod, "module example.test/app\n\ngo 1.25\n") + mustModuleFile(t, appGoMod, "example.test/app") mustWriteFile(t, frameworkGoMod, `module example.test/framework go 1.25 @@ -383,8 +346,8 @@ require ( example.test/first v1.0.0 //xgo:class ) `) - mustWriteFile(t, firstGoMod, "module example.test/first\n\ngo 1.25\n") - mustWriteFile(t, secondGoMod, "module example.test/second\n\ngo 1.25\n") + 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}} @@ -420,10 +383,7 @@ func TestPreparePoliciesIgnoresXGoGoCmd(t *testing.T) { } t.Setenv("XGO_GOCMD", filepath.Join(t.TempDir(), "does-not-exist")) t.Setenv("GOWORK", "off") - policy, err := preparePolicies(context.Background(), t.TempDir(), nil) - if err != nil { - t.Fatal(err) - } + policy := mustPolicies(t, t.TempDir()) if filepath.Base(policy.graph.GoCommand) != "go" { t.Fatalf("Go command = %q", policy.graph.GoCommand) } @@ -450,6 +410,40 @@ func mustMkdirAll(t *testing.T, path string) { } } +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 mustWriteFile(t *testing.T, path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0644); err != nil { diff --git a/cmd/internal/runtimeprovider/output.go b/cmd/internal/runtimeprovider/output.go index 9e4d11f0d..45ed407cb 100644 --- a/cmd/internal/runtimeprovider/output.go +++ b/cmd/internal/runtimeprovider/output.go @@ -185,10 +185,6 @@ func (tx *outputTransaction) abort() { _ = tx.parent.Close() } -func (tx *outputTransaction) commit() error { - return tx.commitContext(context.Background()) -} - // commitContext validates and publishes the staged output. Cancellation is // checked immediately before the rename, which is the transaction's commit // point; cancellation after that point cannot retract an already-published diff --git a/cmd/internal/runtimeprovider/output_test.go b/cmd/internal/runtimeprovider/output_test.go index 276c999b5..5b351ea46 100644 --- a/cmd/internal/runtimeprovider/output_test.go +++ b/cmd/internal/runtimeprovider/output_test.go @@ -41,7 +41,7 @@ func TestOutputTransactionCommit(t *testing.T) { } defer tx.abort() writeTestExecutable(t, tx.staged) - if err := tx.commit(); err != nil { + if err := tx.commitContext(context.Background()); err != nil { t.Fatal(err) } got, err := os.ReadFile(final) @@ -90,7 +90,7 @@ func TestOutputTransactionFailurePreservesFinal(t *testing.T) { if err := os.WriteFile(tx.staged, nil, 0755); err != nil { t.Fatal(err) } - if err := tx.commit(); err == nil { + if err := tx.commitContext(context.Background()); err == nil { t.Fatal("empty staged output committed") } tx.abort() @@ -117,8 +117,8 @@ func TestCanceledBuildDoesNotCommitOutput(t *testing.T) { writeTestExecutable(t, tx.staged) ctx, cancel := context.WithCancel(context.Background()) cancel() - if err := commitOutputUnlessCanceled(ctx, tx); !errors.Is(err, context.Canceled) { - t.Fatalf("commitOutputUnlessCanceled() = %v, want context cancellation", err) + 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" { @@ -155,7 +155,7 @@ func TestOutputTransactionRejectsSymlinksAndExtras(t *testing.T) { if err := os.WriteFile(filepath.Join(tx.dir, "extra"), []byte("extra"), 0600); err != nil { t.Fatal(err) } - if err := tx.commit(); err == nil { + if err := tx.commitContext(context.Background()); err == nil { t.Fatal("extra staged output accepted") } if _, err := os.Stat(final); !os.IsNotExist(err) { @@ -182,7 +182,7 @@ func TestOutputTransactionRejectsFinalSymlinkCreatedAfterBegin(t *testing.T) { if err := os.Symlink(target, final); err != nil { t.Fatal(err) } - if err := tx.commit(); err == nil { + 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" { @@ -222,7 +222,7 @@ func TestOutputTransactionPinsParentAcrossPathSwap(t *testing.T) { t.Fatal(err) } - commitErr := tx.commit() + commitErr := tx.commitContext(context.Background()) if commitErr == nil { t.Fatal("commit accepted a replaced output parent pathname") } @@ -258,7 +258,7 @@ func TestOutputTransactionRejectsWorkDirectorySwap(t *testing.T) { t.Fatal(err) } writeTestExecutable(t, tx.staged) - if err := tx.commit(); err == nil { + if err := tx.commitContext(context.Background()); err == nil { t.Fatal("replaced work directory was accepted") } if _, err := os.Stat(final); !os.IsNotExist(err) { @@ -285,7 +285,7 @@ func TestOutputTransactionSupportsStableParentAlias(t *testing.T) { } defer tx.abort() writeTestExecutable(t, tx.staged) - if err := tx.commit(); err != nil { + 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 { @@ -308,7 +308,7 @@ func TestOutputTransactionWindowsCaseAlias(t *testing.T) { } defer tx.abort() writeTestExecutable(t, tx.staged) - if err := tx.commit(); err != nil { + if err := tx.commitContext(context.Background()); err != nil { t.Fatal(err) } info, err := os.Stat(existing) diff --git a/cmd/internal/runtimeprovider/process_unix.go b/cmd/internal/runtimeprovider/process_unix.go index c94aba099..4e1d28456 100644 --- a/cmd/internal/runtimeprovider/process_unix.go +++ b/cmd/internal/runtimeprovider/process_unix.go @@ -52,11 +52,7 @@ func runProviderProcess(ctx context.Context, cmd *exec.Cmd) (ProcessStatus, erro if _, cleanupErr := stopProviderProcessGroup(pgid, syscall.SIGTERM, nil); cleanupErr != nil { return ProcessStatus{}, cleanupErr } - status, statusErr := processStatus(err) - if statusErr != nil { - return ProcessStatus{}, statusErr - } - return statusUnlessCanceled(ctx, status) + return providerExitStatus(ctx, err) case <-ctx.Done(): } @@ -65,11 +61,7 @@ func runProviderProcess(ctx context.Context, cmd *exec.Cmd) (ProcessStatus, erro if cleanupErr != nil { return ProcessStatus{}, cleanupErr } - status, err := processStatus(err) - if err != nil { - return ProcessStatus{}, err - } - return statusUnlessCanceled(ctx, status) + return providerExitStatus(ctx, err) } func configureProviderProcessGroup(cmd *exec.Cmd) { diff --git a/cmd/internal/runtimeprovider/process_windows.go b/cmd/internal/runtimeprovider/process_windows.go index bb510a042..c4fbc39e6 100644 --- a/cmd/internal/runtimeprovider/process_windows.go +++ b/cmd/internal/runtimeprovider/process_windows.go @@ -95,13 +95,9 @@ func runProviderProcess(ctx context.Context, cmd *exec.Cmd) (ProcessStatus, erro err = cmd.Wait() close(done) <-watchDone - status, err := processStatus(err) - if err != nil { - return ProcessStatus{}, err - } // 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 statusUnlessCanceled(ctx, status) + return providerExitStatus(ctx, err) } func configureSuspendedProvider(cmd *exec.Cmd) { diff --git a/cmd/internal/runtimeprovider/provider.go b/cmd/internal/runtimeprovider/provider.go index 3a7ba562a..3843d6e99 100644 --- a/cmd/internal/runtimeprovider/provider.go +++ b/cmd/internal/runtimeprovider/provider.go @@ -65,7 +65,7 @@ func (r *Resolver) buildWithPolicy(ctx context.Context, rt *Runtime, requestedOu if err != nil || status.Signaled || status.Code != 0 { return status, final, err } - if err := commitOutputUnlessCanceled(ctx, tx); err != nil { + if err := tx.commitContext(ctx); err != nil { return ProcessStatus{}, final, err } if policy.KeepWork { @@ -241,16 +241,6 @@ func validateProvider(ctx context.Context, rt *Runtime) error { return nil } -func sameResolvedModule(a, b ResolvedModule) bool { - if a.Main != b.Main || a.Selected != b.Selected { - return false - } - if a.Replace == nil || b.Replace == nil { - return a.Replace == nil && b.Replace == nil - } - return *a.Replace == *b.Replace -} - func hostBuildEnvironment(base []string, goWork string) []string { env := graphEnvironment(base, goWork) env = replaceEnv(env, "GOOS", runtime.GOOS) @@ -264,15 +254,14 @@ func providerEnvironment(base []string, rt *Runtime) []string { } func fillStreams(streams Streams) Streams { - defaults := defaultStreams() if streams.Stdin == nil { - streams.Stdin = defaults.Stdin + streams.Stdin = os.Stdin } if streams.Stdout == nil { - streams.Stdout = defaults.Stdout + streams.Stdout = os.Stdout } if streams.Stderr == nil { - streams.Stderr = defaults.Stderr + streams.Stderr = os.Stderr } return streams } @@ -305,9 +294,10 @@ func statusUnlessCanceled(ctx context.Context, status ProcessStatus) (ProcessSta return status, nil } -func commitOutputUnlessCanceled(ctx context.Context, tx *outputTransaction) error { - if ctx == nil { - ctx = context.Background() +func providerExitStatus(ctx context.Context, waitErr error) (ProcessStatus, error) { + status, err := processStatus(waitErr) + if err != nil { + return ProcessStatus{}, err } - return tx.commitContext(ctx) + return statusUnlessCanceled(ctx, status) } diff --git a/cmd/internal/runtimeprovider/provider_test.go b/cmd/internal/runtimeprovider/provider_test.go index a0958ffe5..a8340d784 100644 --- a/cmd/internal/runtimeprovider/provider_test.go +++ b/cmd/internal/runtimeprovider/provider_test.go @@ -17,11 +17,180 @@ package runtimeprovider import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" "runtime" "strings" "testing" + "time" + + "github.com/goplus/xgo/x/xgoprojs" ) +func TestRuntimeRunAndBuild(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(t) + resolver := fixture.resolver(t, "-trimpath=true", "-buildvcs=false") + rt := resolveRuntime(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) + var stdout, stderr bytes.Buffer + status, err := resolver.Run(context.Background(), rt, []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(), rt, 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 TestRuntimeBuildFailurePreservesOutput(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(t) + resolver := fixture.resolver(t) + rt := resolveRuntime(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_PROVIDER_EXIT", "42") + status, _, err := resolver.Build(context.Background(), rt, 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 TestRuntimeBuildCancellationPreservesOutput(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(t) + resolver := fixture.resolver(t) + rt := resolveRuntime(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, "provider-started") + t.Setenv("FAKE_PROVIDER_MARKER", marker) + t.Setenv("FAKE_PROVIDER_BLOCK", "1") + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + _, _, _ = resolver.Build(ctx, rt, 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("runtime provider did not start") + } + time.Sleep(10 * time.Millisecond) + } + cancel() + select { + case <-done: + case <-time.After(15 * time.Second): + t.Fatal("canceled runtime provider 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-runtime-output-*")) + if err != nil { + t.Fatal(err) + } + if len(matches) != 0 { + t.Fatalf("runtime output work directories remain: %v", matches) + } +} + +func TestRuntimeInstallUsesEffectiveGOBIN(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(t) + resolver := fixture.resolver(t) + rt := resolveRuntime(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(), rt, 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 TestRuntimeInstallValidatesPolicyBeforeCreatingGOBIN(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(t) + resolver := fixture.resolver(t, "-tags=unsupported") + rt := resolveRuntime(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(), rt, 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", diff --git a/cmd/internal/runtimeprovider/resolve.go b/cmd/internal/runtimeprovider/resolve.go index d7e2facd0..b892ec16f 100644 --- a/cmd/internal/runtimeprovider/resolve.go +++ b/cmd/internal/runtimeprovider/resolve.go @@ -17,34 +17,18 @@ package runtimeprovider import ( - "bytes" "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" "errors" "fmt" - "io" "os" "path/filepath" - "regexp" "strings" - "github.com/goplus/mod/modfile" "github.com/goplus/mod/modload" - "github.com/goplus/mod/xgomod" "github.com/goplus/xgo/env" "github.com/goplus/xgo/x/xgoprojs" - gomodfile "golang.org/x/mod/modfile" - "golang.org/x/mod/module" ) -const runtimeGuardEnv = "XGO_RUNTIME_GUARD" - -func runtimeGuard(projectDir, providerPackage string) string { - return sha256Bytes([]byte(projectDir + "\x00" + providerPackage)) -} - // Resolver owns one invocation's graph and host-build policies. type Resolver struct { cwd string @@ -89,6 +73,7 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, recursivePattern bool graph *effectiveGraph graphWorkDir string + err error ) switch target := target.(type) { case *xgoprojs.DirProj: @@ -99,7 +84,6 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, recursivePattern = true candidate = trimRecursivePattern(candidate) } - var err error projectDir, err = canonicalExistingDir(candidate) if err != nil { if (os.IsNotExist(errors.Unwrap(err)) || os.IsNotExist(err)) && r.policy.graph.Overlay != "" { @@ -211,75 +195,17 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, graphPolicy := r.policy.graph graphPolicy.WorkDir = graphWorkDir - if graph == nil { - // An overlay changes the Go command's effective module graph and may - // introduce class markers or runtime metadata that are absent from the - // physical tree. Classify through that same graph before the physical - // preflight; otherwise a runtime target can fall through to legacy. - if overlay := graphPolicy.Overlay; overlay != "" { - overlayGraph, graphErr := loadEffectiveGraph(ctx, projectDir, graphPolicy) - if graphErr != nil { - if errors.Is(graphErr, errNoGoModule) { - return nil, ErrNotHandled - } - return nil, graphErr - } - hasRuntime, matchErr := overlayRuntimeProjectMatch(projectDir, overlayGraph, recursivePattern) - if matchErr != nil { - return nil, matchErr - } - if !hasRuntime { - return nil, ErrNotHandled - } - return nil, unsupportedOverlayError(overlay) - } - preflightModule, _, hasClass, vendor, err := r.preflightClassMetadataDetails(ctx, projectDir) - if err != nil { - if errors.Is(err, errNoGoModule) { - return nil, ErrNotHandled - } - return nil, err - } - if !hasClass { - return nil, ErrNotHandled - } - if vendor { - hasRuntime, probeErr := r.probeVendorProject(projectDir, preflightModule, recursivePattern) - if probeErr != nil { - return nil, probeErr - } - if !hasRuntime { - return nil, ErrNotHandled - } - return nil, vendorUnsupportedError(string(r.policy.graph.ModMode)) - } - graph, err = loadEffectiveGraph(ctx, projectDir, r.policy.graph) - if err != nil { - return nil, err - } - } else { - if graph.files != nil && graph.files.hasOverlay() { - hasRuntime, matchErr := overlayRuntimeProjectMatch(projectDir, graph, recursivePattern) - if matchErr != nil { - return nil, matchErr - } - if !hasRuntime { - return nil, ErrNotHandled - } - return nil, unsupportedOverlayError(graphPolicy.Overlay) - } - hasClass, err := graphHasClassMetadata(graph) - if err != nil { - return nil, err - } - if !hasClass { - return nil, ErrNotHandled - } + graph, err = r.resolveTargetGraph(ctx, projectDir, graph, graphPolicy, recursivePattern) + if err != nil { + return nil, err } - module, _, err := loadResolvedClasses(graph) + module, hasClass, err := loadResolvedClasses(graph) if err != nil { return nil, err } + if !hasClass { + return nil, ErrNotHandled + } if recursivePattern { hasRuntime, err := patternContainsRuntimeProject(projectDir, module) if err != nil { @@ -360,1122 +286,58 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, }, nil } -// versionedPackageHasRuntime classifies the requested module version in an -// isolated graph. It must not borrow metadata from the caller's selected build -// list: the same package path may describe a legacy project in one version and -// a runtime project in another. -func (r *Resolver) versionedPackageHasRuntime(ctx context.Context, target string) (bool, error) { - importPath, query, ok := splitVersionedPackageTarget(target) - if !ok { - return false, nil - } - - probeDir, err := os.MkdirTemp("", "xgo-runtime-version-probe-") - if err != nil { - return false, fmt.Errorf("create versioned package graph probe: %w", err) - } - defer os.RemoveAll(probeDir) - probeMod := "module xgo.dev/runtimeprovider/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 { - // A failed go get remains the legacy path's diagnostic responsibility. - // XGo-only packages may not be Go packages at all, so continue with the - // structured module probe below without inferring anything from stderr. - 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/runtimeprovider/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 -m all` may omit source fields for an otherwise selected - // dependency. `go list -find` is authoritative for this package and has - // already supplied a fully materialized module record. - 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, _, err := loadResolvedClasses(graph) - if err != nil { - return false, fmt.Errorf("load versioned package %q runtime metadata: %w", target, err) - } - _, 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.Runtime != 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 -} - -// resolveOverlayLocalTarget disambiguates a local argument that does not exist -// on disk. xgoprojs parses both an overlay-only directory and an overlay-only -// file as DirProj, so classification must consult the same virtual filesystem -// as the Go command before deciding that the target belongs to the legacy path. -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 +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 } - 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 -} - -func (r *Resolver) preflightClassMetadata(ctx context.Context, dir string) (hasClass, vendor bool, err error) { - _, _, hasClass, vendor, err = r.preflightClassMetadataDetails(ctx, dir) - return -} - -// preflightClassMetadataDetails reads only the target module metadata. It is -// intentionally separate from loadEffectiveGraph: go list -m all cannot run -// in vendor mode, and probing a legacy target must not turn that limitation -// into a runtime-provider error before a runtime project has been identified. -func (r *Resolver) preflightClassMetadataDetails(ctx context.Context, dir string) (loaded modload.Module, moduleGoMod string, hasClass, vendor bool, err error) { - goMod, err := goEnvWithPolicy(ctx, r.policy.graph, dir, "GOMOD") - 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 = loadRuntimeModule(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 identifies a runtime project without invoking -// "go list -m all". Standard Go vendor snapshots do not preserve gox.mod or -// gop.mod reliably, so an external class marker is indeterminate and must fail -// closed instead of consulting a live replacement or returning ErrNotHandled. -func (r *Resolver) probeVendorProject(projectDir string, target modload.Module, recursive bool) (bool, error) { - if classPath := externalClassModule(target); classPath != "" { - return false, r.vendorClassMetadataError(classPath) - } - return matchVendorProjects(projectDir, target.Projects(), recursive) -} - -// probeVendorPackage uses package-specific `go list`, which remains available -// in vendor mode, to distinguish another workspace main module from an -// unmarked dependency. Only main/workspace module metadata is authoritative; -// external class metadata is absent from standard vendor data and fails closed. -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 provider 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 := loadRuntimeModule(module.Effective().GoMod, filepath.Join(root, "gox.mod")) - if err != nil { - return false, err - } - if classPath := externalClassModule(loaded); classPath != "" { - return false, r.vendorClassMetadataError(classPath) - } - return matchVendorProjects(projectDir, loaded.Projects(), 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 - } - ownerGoMod, err := goEnvWithPolicy(ctx, r.policy.graph, projectDir, "GOMOD") - if err != nil { - return false, err - } - if ownerGoMod == "" || ownerGoMod == os.DevNull { - return false, nil - } - same, err := sameFile(moduleGoMod, ownerGoMod) - if err != nil { - return false, err - } - if !same { - return false, nil - } - return matchVendorProjects(projectDir, target.Projects(), recursive) -} - -type workspaceVendorMember struct { - modulePath string - root string - goMod string -} - -// probeVendorWorkspacePackage handles XGo-only packages for which go list -// cannot report physical package fields. Only go.work use members are eligible: -// workspace replacements and other live dependency sources are deliberately -// excluded from this conservative vendor-mode classification. -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 := loadRuntimeModule(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 runtime 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 := goEnvWithPolicy(ctx, r.policy.graph, projectDir, "GOMOD") - 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) + hasRuntime, err := overlayRuntimeProjectMatch(projectDir, graph, recursive) 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 -} - -// loadRuntimeModule preserves modload's gox.mod-to-gop.mod fallback while -// distinguishing an absent optional metadata file from an unreadable one. -// Runtime ownership must not fall back to the legacy path on metadata I/O -// failures. -func loadRuntimeModule(goMod, goxMod string) (modload.Module, error) { - return loadRuntimeModuleView(goMod, goxMod, nil) -} - -func loadRuntimeModuleView(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 (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 matchVendorProjects(dir string, projects []*modfile.Project, recursive bool) (bool, error) { - if recursive { - return patternContainsRuntimeProjects(dir, projects) - } - return hasRuntimeProject(dir, projects) -} - -func hasRuntimeProject(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 - } - ext := modfile.ClassExt(entry.Name()) - for _, project := range projects { - if project.Runtime != nil && project.IsProj(ext, entry.Name()) { - return true, nil - } - } - } - return false, nil -} - -func graphHasClassMetadata(graph *effectiveGraph) (bool, error) { - loaded, err := loadRuntimeModuleView(graph.TargetModFile.Path, filepath.Join(graph.Target.Effective().Dir, "gox.mod"), graph.files) - if err != nil { - return false, err - } - return len(graph.ClassModules) != 0 || loaded.HasProject(), nil -} - -// overlayRuntimeProjectMatch classifies a target using the effective graph's -// overlay-aware metadata. It is intentionally a classification-only path: -// v1 has no snapshot contract for overlays, so a positive match is converted -// to an explicit unsupported error before any provider Runtime is built. -func overlayRuntimeProjectMatch(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 := overlayRuntimeProjects(graph) - if err != nil { - return false, err - } - if len(projects) == 0 { - return false, nil - } - if recursive { - return overlayWalkRuntimeProjects(projectDir, projects, graph.files) - } - names, err := graph.files.regularFileNames(projectDir) - if err != nil { - return false, err - } - for _, name := range names { - ext := modfile.ClassExt(name) - for _, project := range projects { - if project.Runtime != nil && project.IsProj(ext, name) { - return true, nil - } - } - } - return false, nil -} - -func overlayRuntimeProjects(graph *effectiveGraph) ([]*modfile.Project, error) { - target := graph.Target.Effective() - loaded, err := loadRuntimeModuleView(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 := loadRuntimeModuleView(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) + if !hasRuntime { + return nil, ErrNotHandled } - projects = append(projects, classModule.Projects()...) + return nil, unsupportedOverlayError(policy.Overlay) } - return projects, nil -} - -func overlayWalkRuntimeProjects(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) + // An overlay changes the Go command's effective module graph and may + // introduce class markers or runtime metadata absent from the physical tree. + if overlay := policy.Overlay; overlay != "" { + overlayGraph, err := loadEffectiveGraph(ctx, projectDir, policy) if err != nil { - return err - } - for _, name := range names { - ext := modfile.ClassExt(name) - for _, project := range projects { - if project.Runtime != nil && project.IsProj(ext, name) { - return errRuntimeProjectInPattern - } + if errors.Is(err, errNoGoModule) { + return nil, ErrNotHandled } + return nil, err } - dirs, err := view.directoryNames(current) + hasRuntime, err := overlayRuntimeProjectMatch(projectDir, overlayGraph, recursive) if err != nil { - return err + return nil, 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 - } + if !hasRuntime { + return nil, ErrNotHandled } - return nil - } - err := walk(root) - if errors.Is(err, errRuntimeProjectInPattern) { - return true, nil + return nil, unsupportedOverlayError(overlay) } - return false, err -} - -func unsupportedOverlayError(path string) error { - return fmt.Errorf("runtime provider v1 does not support flag -overlay=%s", path) -} - -func goEnvWithPolicy(ctx context.Context, policy GraphPolicy, dir, key string) (string, error) { - args := []string{"env", key} - cmd := commandContext(ctx, policy.GoCommand, args...) - cmd.Dir = dir - // Graph flags do not affect go env values and are deliberately never - // reconstructed into GOFLAGS; all graph operations pass them via argv. - cmd.Env = graphEnvironment(os.Environ(), policy.GoWork) - out, err := cmd.Output() + preflightModule, _, hasClass, vendor, err := r.preflightClassMetadataDetails(ctx, projectDir) if err != nil { - return "", commandError("go env "+key, err, string(cmdStderr(cmd))) - } - return strings.TrimSpace(string(out)), nil -} - -func loadResolvedClasses(graph *effectiveGraph) (*xgomod.Module, bool, error) { - target := graph.Target.Effective() - loaded, err := loadRuntimeModule(graph.TargetModFile.Path, filepath.Join(target.Dir, "gox.mod")) - if err != nil { - return nil, false, err - } - module := xgomod.New(loaded) - hasRuntime := false - if err := module.ImportClassesResolved(toXGoGraph(graph), func(info *xgomod.ProjectInfo) { - if info != nil && info.Project != nil && info.Project.Runtime != nil { - hasRuntime = true - } - }); err != nil { - return nil, false, err - } - return module, hasRuntime, 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.Runtime != nil { - projectFile = filepath.Join(dir, entry.Name()) - projectInfo = classInfo - } - } - return projectFile, projectInfo, count, nil -} - -var errRuntimeProjectInPattern = errors.New("runtime project in pattern") - -func patternContainsRuntimeProject(root string, module *xgomod.Module) (bool, error) { - return walkRuntimePattern(root, func(dir string) (bool, error) { - _, info, _, err := findProjectFile(dir, module) - return info != nil && info.Project != nil && info.Project.Runtime != nil, err - }) -} - -func patternContainsRuntimeProjects(root string, projects []*modfile.Project) (bool, error) { - return walkRuntimePattern(root, func(dir string) (bool, error) { - return hasRuntimeProject(dir, projects) - }) -} - -func walkRuntimePattern(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 errRuntimeProjectInPattern + if errors.Is(err, errNoGoModule) { + return nil, ErrNotHandled } - return nil - }) - if errors.Is(err, errRuntimeProjectInPattern) { - 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("runtime pack directory %q is invalid", pack.Directory) - } - if pack.IndexFile == "" || filepath.Base(pack.IndexFile) != pack.IndexFile || strings.ContainsAny(pack.IndexFile, `/\`) { - return "", "", fmt.Errorf("runtime pack index %q is invalid", pack.IndexFile) - } - root := filepath.Join(projectDir, dir) - canonical, err := canonicalExistingDir(root) - if err != nil { - return "", "", fmt.Errorf("runtime pack directory: %w", err) - } - if !pathWithin(projectDir, canonical) { - return "", "", fmt.Errorf("runtime pack directory escapes the project") - } - return filepath.ToSlash(dir), pack.IndexFile, nil -} - -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("runtime origin %q has an invalid declaring metadata snapshot", origin.Selected.Path) - } - if len(snapshot.SHA256) != sha256.Size*2 { - return fileIdentity{}, fmt.Errorf("runtime 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("runtime 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 -} - -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 nil, err } - return os.SameFile(aInfo, bInfo), nil -} - -func effectiveVendorMode(policy GraphPolicy, moduleGoMod string, parsed *gomodfile.File) (bool, error) { - if policy.ModMode != "" { - return policy.ModMode == modModeVendor, nil + if !hasClass { + return nil, ErrNotHandled } - workspace := policy.GoWork != "" && policy.GoWork != "off" - var ( - goVersion string - vendorDir string - ) - if workspace { - data, err := os.ReadFile(policy.GoWork) + if vendor { + hasRuntime, err := r.probeVendorProject(projectDir, preflightModule, recursive) 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 + return nil, err } - 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 modulesTextIsForWorkspace. -// A missing modules.txt retains the historical module-vendor behavior, but it -// cannot identify a workspace vendor directory. -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 + if !hasRuntime { + return nil, ErrNotHandled } + return nil, vendorUnsupportedError(string(r.policy.graph.ModMode)) } - 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", ErrRuntimeVendorUnsupported, mode) -} - -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[:]) + return loadEffectiveGraph(ctx, projectDir, r.policy.graph) } diff --git a/cmd/internal/runtimeprovider/resolve_overlay.go b/cmd/internal/runtimeprovider/resolve_overlay.go new file mode 100644 index 000000000..ca1cf5ade --- /dev/null +++ b/cmd/internal/runtimeprovider/resolve_overlay.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 runtimeprovider + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/goplus/mod/modfile" +) + +// resolveOverlayLocalTarget disambiguates a local argument that does not exist +// on disk. xgoprojs parses both an overlay-only directory and an overlay-only +// file as DirProj, so classification must consult the same virtual filesystem +// as the Go command before deciding that the target belongs to the legacy path. +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 +} + +// overlayRuntimeProjectMatch classifies a target using the effective graph's +// overlay-aware metadata. It is intentionally a classification-only path: +// v1 has no snapshot contract for overlays, so a positive match is converted +// to an explicit unsupported error before any provider Runtime is built. +func overlayRuntimeProjectMatch(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 := overlayRuntimeProjects(graph) + if err != nil { + return false, err + } + if len(projects) == 0 { + return false, nil + } + if recursive { + return overlayWalkRuntimeProjects(projectDir, projects, graph.files) + } + names, err := graph.files.regularFileNames(projectDir) + if err != nil { + return false, err + } + for _, name := range names { + ext := modfile.ClassExt(name) + for _, project := range projects { + if project.Runtime != nil && project.IsProj(ext, name) { + return true, nil + } + } + } + return false, nil +} + +func overlayRuntimeProjects(graph *effectiveGraph) ([]*modfile.Project, error) { + target := graph.Target.Effective() + loaded, err := loadRuntimeModuleView(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 := loadRuntimeModuleView(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 overlayWalkRuntimeProjects(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 { + ext := modfile.ClassExt(name) + for _, project := range projects { + if project.Runtime != nil && project.IsProj(ext, name) { + return errRuntimeProjectInPattern + } + } + } + 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, errRuntimeProjectInPattern) { + return true, nil + } + return false, err +} + +func unsupportedOverlayError(path string) error { + return fmt.Errorf("runtime provider v1 does not support flag -overlay=%s", path) +} diff --git a/cmd/internal/runtimeprovider/resolve_overlay_test.go b/cmd/internal/runtimeprovider/resolve_overlay_test.go new file mode 100644 index 000000000..980f481db --- /dev/null +++ b/cmd/internal/runtimeprovider/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 runtimeprovider + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/goplus/xgo/x/xgoprojs" +) + +func TestResolveOverlayClassifiesRuntimeBeforePhysicalPreflight(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", "provider")) + 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 +runtime v1 example.test/framework/cmd/provider +`) + mustWriteFile(t, filepath.Join(framework, "cmd", "provider", "main.go"), fakeProviderSource) + mustWriteFile(t, filepath.Join(project, "main.foo"), "// overlaid runtime 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 := overlayRuntimeProjectMatch(canonicalProject, graph, false) + if matchErr != nil || !matched { + t.Fatalf("overlay classification = %v, %v; want runtime 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 runtime 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 runtime 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 +runtime v1 example.test/app/cmd/provider +`) + mustWriteFile(t, filepath.Join(actual, "main.go"), "package game\n") + mustWriteFile(t, filepath.Join(actual, "main.foo"), "// overlaid runtime 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/runtimeprovider/resolve_project.go b/cmd/internal/runtimeprovider/resolve_project.go new file mode 100644 index 000000000..df9a3abde --- /dev/null +++ b/cmd/internal/runtimeprovider/resolve_project.go @@ -0,0 +1,303 @@ +/* + * 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 runtimeprovider + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/goplus/mod/modfile" + "github.com/goplus/mod/modload" + "github.com/goplus/mod/xgomod" +) + +const runtimeGuardEnv = "XGO_RUNTIME_GUARD" + +func runtimeGuard(projectDir, providerPackage string) string { + return sha256Bytes([]byte(projectDir + "\x00" + providerPackage)) +} + +// loadRuntimeModule preserves modload's gox.mod-to-gop.mod fallback while +// distinguishing an absent optional metadata file from an unreadable one. +// Runtime ownership must not fall back to the legacy path on metadata I/O +// failures. +func loadRuntimeModule(goMod, goxMod string) (modload.Module, error) { + return loadRuntimeModuleView(goMod, goxMod, nil) +} + +func loadRuntimeModuleView(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 loadResolvedClasses(graph *effectiveGraph) (*xgomod.Module, bool, error) { + target := graph.Target.Effective() + loaded, err := loadRuntimeModuleView(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.Runtime != nil { + projectFile = filepath.Join(dir, entry.Name()) + projectInfo = classInfo + } + } + return projectFile, projectInfo, count, nil +} + +var errRuntimeProjectInPattern = errors.New("runtime project in pattern") + +func patternContainsRuntimeProject(root string, module *xgomod.Module) (bool, error) { + return walkRuntimePattern(root, func(dir string) (bool, error) { + _, info, _, err := findProjectFile(dir, module) + return info != nil && info.Project != nil && info.Project.Runtime != nil, err + }) +} + +func patternContainsRuntimeProjects(root string, projects []*modfile.Project) (bool, error) { + return walkRuntimePattern(root, func(dir string) (bool, error) { + return hasRuntimeProject(dir, projects) + }) +} + +func walkRuntimePattern(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 errRuntimeProjectInPattern + } + return nil + }) + if errors.Is(err, errRuntimeProjectInPattern) { + 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("runtime pack directory %q is invalid", pack.Directory) + } + if pack.IndexFile == "" || filepath.Base(pack.IndexFile) != pack.IndexFile || strings.ContainsAny(pack.IndexFile, `/\`) { + return "", "", fmt.Errorf("runtime pack index %q is invalid", pack.IndexFile) + } + root := filepath.Join(projectDir, dir) + canonical, err := canonicalExistingDir(root) + if err != nil { + return "", "", fmt.Errorf("runtime pack directory: %w", err) + } + if !pathWithin(projectDir, canonical) { + return "", "", fmt.Errorf("runtime pack directory escapes the project") + } + return filepath.ToSlash(dir), pack.IndexFile, nil +} + +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("runtime origin %q has an invalid declaring metadata snapshot", origin.Selected.Path) + } + if len(snapshot.SHA256) != sha256.Size*2 { + return fileIdentity{}, fmt.Errorf("runtime 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("runtime 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 +} + +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/runtimeprovider/resolve_test.go b/cmd/internal/runtimeprovider/resolve_test.go index 40decbc96..819800b80 100644 --- a/cmd/internal/runtimeprovider/resolve_test.go +++ b/cmd/internal/runtimeprovider/resolve_test.go @@ -19,183 +19,22 @@ package runtimeprovider import ( "bytes" "context" - "encoding/json" "errors" "fmt" "net/url" "os" "os/exec" "path/filepath" - "runtime" "sort" "strings" "testing" - "time" - "github.com/goplus/mod/modfile" - "github.com/goplus/mod/modload" "github.com/goplus/mod/xgomod" "github.com/goplus/xgo/x/xgoprojs" "golang.org/x/mod/module" modzip "golang.org/x/mod/zip" ) -func TestResolveOverlayClassifiesRuntimeBeforePhysicalPreflight(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", "provider")) - 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 -runtime v1 example.test/framework/cmd/provider -`) - mustWriteFile(t, filepath.Join(framework, "cmd", "provider", "main.go"), fakeProviderSource) - mustWriteFile(t, filepath.Join(project, "main.foo"), "// overlaid runtime 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 := overlayRuntimeProjectMatch(canonicalProject, graph, false) - if matchErr != nil || !matched { - t.Fatalf("overlay classification = %v, %v; want runtime 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 runtime 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 runtime 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 -runtime v1 example.test/app/cmd/provider -`) - mustWriteFile(t, filepath.Join(actual, "main.go"), "package game\n") - mustWriteFile(t, filepath.Join(actual, "main.foo"), "// overlaid runtime 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) - } -} - type runtimeFixture struct { root string app string @@ -246,6 +85,35 @@ func (f runtimeFixture) resolver(t *testing.T, flags ...string) *Resolver { return resolver } +func resolveRuntime(t *testing.T, resolver *Resolver, target xgoprojs.Proj) *Runtime { + t.Helper() + runtime, err := resolver.Resolve(context.Background(), target) + if err != nil { + t.Fatal(err) + } + return runtime +} + +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 TestResolveRuntimeTargets(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") @@ -257,10 +125,7 @@ func TestResolveRuntimeTargets(t *testing.T) { "package": &xgoprojs.PkgPathProj{Path: "example.test/app/game"}, } { t.Run(name, func(t *testing.T) { - rt, err := fixture.resolver(t).Resolve(context.Background(), target) - if err != nil { - t.Fatal(err) - } + rt := resolveRuntime(t, fixture.resolver(t), target) if rt.ProjectDir != canonicalDir(t, fixture.project) || rt.ProjectFile != canonicalFile(t, fixture.mainFile) { t.Fatalf("project identity = %q, %q", rt.ProjectDir, rt.ProjectFile) } @@ -312,10 +177,7 @@ func TestResolvePackageTargetKeepsCallerGraph(t *testing.T) { mustWriteFile(t, filepath.Join(dependencyProject, "pack", "index.data"), "{}\n") resolver := fixture.resolver(t) - rt, err := resolver.Resolve(context.Background(), &xgoprojs.PkgPathProj{Path: "example.test/framework/example"}) - if err != nil { - t.Fatal(err) - } + rt := resolveRuntime(t, resolver, &xgoprojs.PkgPathProj{Path: "example.test/framework/example"}) if rt.ModuleRoot != canonicalDir(t, fixture.framework) || rt.Graph.WorkDir != canonicalDir(t, fixture.app) { t.Fatalf("graph roots = module %q, work %q", rt.ModuleRoot, rt.Graph.WorkDir) } @@ -611,10 +473,7 @@ func TestResolveRuntimeWithoutPack(t *testing.T) { if err := os.WriteFile(goxmod, data, 0644); err != nil { t.Fatal(err) } - rt, err := fixture.resolver(t).Resolve(context.Background(), &xgoprojs.DirProj{Dir: fixture.project}) - if err != nil { - t.Fatal(err) - } + rt := resolveRuntime(t, fixture.resolver(t), &xgoprojs.DirProj{Dir: fixture.project}) if rt.PackDir != "" || rt.PackIndex != "" { t.Fatalf("pack = %q, %q", rt.PackDir, rt.PackIndex) } @@ -670,460 +529,6 @@ func TestResolveRuntimeRejectsAmbiguousAndMultiFile(t *testing.T) { } } -func TestResolveRuntimeVendorFailsClosed(t *testing.T) { - if testing.Short() { - t.Skip("invokes the host Go command") - } - fixture := newRuntimeFixture(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, ErrRuntimeVendorUnsupported) { - 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, ErrRuntimeVendorUnsupported) { - t.Fatalf("external class vendor target = %v, want ErrRuntimeVendorUnsupported", err) - } - } else if !errors.Is(err, ErrNotHandled) { - t.Fatalf("main-module legacy vendor target = %v, want ErrNotHandled", err) - } - }) - } -} - -func TestResolveRuntimeTargetStillRejectsVendor(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\nruntime v1 example.test/framework/cmd/provider\n") - mustWriteFile(t, filepath.Join(project, "main.foo"), "// runtime 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, ErrRuntimeVendorUnsupported) { - t.Fatalf("runtime 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", "provider")) - 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/provider\"\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 -runtime v1 example.test/framework/cmd/provider -`) - mustWriteFile(t, filepath.Join(framework, "cmd", "provider", "provider.go"), "package provider\n") - mustWriteFile(t, filepath.Join(project, "main.foo"), "// runtime project\n") - mustRunGo(t, app, "off", "mod", "vendor") - vendoredFramework := filepath.Join(app, "vendor", "example.test", "framework") - if _, err := os.Stat(filepath.Join(vendoredFramework, "cmd", "provider", "provider.go")); err != nil { - t.Fatalf("real vendor snapshot omitted imported provider 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, ErrRuntimeVendorUnsupported) { - t.Fatalf("real vendored class target = %v, want ErrRuntimeVendorUnsupported", 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", "provider")) - 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/provider\"\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\nruntime v1 example.test/framework/cmd/provider\n") - mustWriteFile(t, filepath.Join(framework, "cmd", "provider", "provider.go"), "package provider\n") - mustWriteFile(t, filepath.Join(project, "main.foo"), "// runtime 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, ErrRuntimeVendorUnsupported) { - t.Fatalf("workspace vendored class target = %v, want ErrRuntimeVendorUnsupported", err) - } -} - -func TestResolveWorkspaceVendorPackageUsesTargetModuleMetadata(t *testing.T) { - if testing.Short() { - t.Skip("invokes the host Go command") - } - root := t.TempDir() - caller := filepath.Join(root, "caller") - runtimeModule := filepath.Join(root, "runtime") - runtimeProject := filepath.Join(runtimeModule, "game") - legacyModule := filepath.Join(root, "legacy") - legacyProject := filepath.Join(legacyModule, "game") - mustMkdirAll(t, caller) - mustMkdirAll(t, runtimeProject) - mustMkdirAll(t, legacyProject) - mustWriteFile(t, filepath.Join(root, "go.work"), `go 1.25 - -use ( - ./caller - ./runtime - ./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(runtimeModule, "go.mod"), "module example.test/runtime\n\ngo 1.25\n") - mustWriteFile(t, filepath.Join(runtimeModule, "gox.mod"), "xgo 1.8\nproject main.foo Game example.test/runtime\nruntime v1 example.test/runtime/cmd/provider\n") - mustWriteFile(t, filepath.Join(runtimeProject, "main.foo"), "// runtime 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/runtime/game"}); !errors.Is(err, ErrRuntimeVendorUnsupported) { - t.Fatalf("workspace runtime package in vendor mode = %v, want ErrRuntimeVendorUnsupported", 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 runtime, err := resolver.probeVendorWorkspacePackage(context.Background(), "example.test/runtime/game", false); err != nil || !runtime { - t.Fatalf("workspace fallback runtime classification = %v, %v; want true", runtime, err) - } - if runtime, err := resolver.probeVendorWorkspacePackage(context.Background(), "example.test/legacy/game", false); err != nil || runtime { - t.Fatalf("workspace fallback legacy classification = %v, %v; want false", runtime, 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, ErrRuntimeVendorUnsupported) { - t.Fatalf("workspace external class classification = %v, want ErrRuntimeVendorUnsupported", 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\nruntime v1 example.test/shared/sub/cmd/provider\n") - mustWriteFile(t, filepath.Join(childProject, "main.foo"), "// runtime 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 runtime, err := resolver.probeVendorWorkspacePackage(context.Background(), "example.test/shared/sub/game", false); err != nil || !runtime { - t.Fatalf("longest workspace member classification = %v, %v; want true", runtime, 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\nruntime v1 example.test/app/cmd/provider\n") - mustWriteFile(t, filepath.Join(app, "app.go"), "package app\n") - mustWriteFile(t, filepath.Join(project, "main.foo"), "// runtime 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, ErrRuntimeVendorUnsupported) { - t.Fatalf("recursive vendor target = %v, want ErrRuntimeVendorUnsupported", err) - } - }) - } -} - -func TestVendorRuntimeProbePropagatesFilesystemErrors(t *testing.T) { - projects := []*modfile.Project{{Runtime: &modfile.Runtime{Protocol: "v1", Package: "example.test/provider"}}} - if _, err := hasRuntimeProject(filepath.Join(t.TempDir(), "missing"), projects); err == nil { - t.Fatal("missing project directory was classified as no runtime") - } - - 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) - } -} - -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 TestResolveVendorIgnoresUnmarkedDependencyRuntime(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\nruntime v1 example.test/framework/cmd/provider\n") - mustWriteFile(t, filepath.Join(project, "main.foo"), "// unmarked dependency runtime 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 runtime = %v, want ErrNotHandled", err) - } -} - func TestResolveNonRuntimeNotHandled(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") @@ -1172,182 +577,6 @@ func TestResolverDefersMissingGraphFileForLegacy(t *testing.T) { } } -func TestRuntimeRunAndBuild(t *testing.T) { - if testing.Short() { - t.Skip("invokes the host Go command") - } - fixture := newRuntimeFixture(t) - resolver := fixture.resolver(t, "-trimpath=true", "-buildvcs=false") - rt, err := resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: fixture.project}) - if err != nil { - t.Fatal(err) - } - var stdout, stderr bytes.Buffer - status, err := resolver.Run(context.Background(), rt, []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(), rt, 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 TestRuntimeBuildFailurePreservesOutput(t *testing.T) { - if testing.Short() { - t.Skip("invokes the host Go command") - } - fixture := newRuntimeFixture(t) - resolver := fixture.resolver(t) - rt, err := resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: fixture.project}) - if err != nil { - t.Fatal(err) - } - 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_PROVIDER_EXIT", "42") - status, _, err := resolver.Build(context.Background(), rt, 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 TestRuntimeBuildCancellationPreservesOutput(t *testing.T) { - if testing.Short() { - t.Skip("invokes the host Go command") - } - fixture := newRuntimeFixture(t) - resolver := fixture.resolver(t) - rt, err := resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: fixture.project}) - if err != nil { - t.Fatal(err) - } - 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, "provider-started") - t.Setenv("FAKE_PROVIDER_MARKER", marker) - t.Setenv("FAKE_PROVIDER_BLOCK", "1") - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan struct{}) - go func() { - defer close(done) - _, _, _ = resolver.Build(ctx, rt, 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("runtime provider did not start") - } - time.Sleep(10 * time.Millisecond) - } - cancel() - select { - case <-done: - case <-time.After(15 * time.Second): - t.Fatal("canceled runtime provider 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-runtime-output-*")) - if err != nil { - t.Fatal(err) - } - if len(matches) != 0 { - t.Fatalf("runtime output work directories remain: %v", matches) - } -} - -func TestRuntimeInstallUsesEffectiveGOBIN(t *testing.T) { - if testing.Short() { - t.Skip("invokes the host Go command") - } - fixture := newRuntimeFixture(t) - resolver := fixture.resolver(t) - rt, err := resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: fixture.project}) - if err != nil { - t.Fatal(err) - } - bin := filepath.Join(fixture.root, "custom-bin") - t.Setenv("GOBIN", bin) - status, final, err := resolver.Install(context.Background(), rt, 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 TestRuntimeInstallValidatesPolicyBeforeCreatingGOBIN(t *testing.T) { - if testing.Short() { - t.Skip("invokes the host Go command") - } - fixture := newRuntimeFixture(t) - resolver := fixture.resolver(t, "-tags=unsupported") - rt, err := resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: fixture.project}) - if err != nil { - t.Fatal(err) - } - bin := filepath.Join(fixture.root, "must-not-exist", "bin") - t.Setenv("GOBIN", bin) - status, final, err := resolver.Install(context.Background(), rt, 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 TestCheckRequiredXGo(t *testing.T) { tests := []struct { required string diff --git a/cmd/internal/runtimeprovider/resolve_vendor.go b/cmd/internal/runtimeprovider/resolve_vendor.go new file mode 100644 index 000000000..fb6ce4401 --- /dev/null +++ b/cmd/internal/runtimeprovider/resolve_vendor.go @@ -0,0 +1,418 @@ +/* + * 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 runtimeprovider + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/goplus/mod/modfile" + "github.com/goplus/mod/modload" + gomodfile "golang.org/x/mod/modfile" +) + +// preflightClassMetadataDetails reads only the target module metadata. It is +// intentionally separate from loadEffectiveGraph: go list -m all cannot run +// in vendor mode, and probing a legacy target must not turn that limitation +// into a runtime-provider error before a runtime project has been identified. +func (r *Resolver) preflightClassMetadataDetails(ctx context.Context, dir string) (loaded modload.Module, moduleGoMod string, hasClass, vendor bool, err error) { + goMod, err := goEnvWithPolicy(ctx, r.policy.graph, dir, "GOMOD") + 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 = loadRuntimeModule(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 identifies a runtime project without invoking +// "go list -m all". Standard Go vendor snapshots do not preserve gox.mod or +// gop.mod reliably, so an external class marker is indeterminate and must fail +// closed instead of consulting a live replacement or returning ErrNotHandled. +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 remains available +// in vendor mode, to distinguish another workspace main module from an +// unmarked dependency. Only main/workspace module metadata is authoritative; +// external class metadata is absent from standard vendor data and fails closed. +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 provider 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 := loadRuntimeModule(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 + } + ownerGoMod, err := goEnvWithPolicy(ctx, r.policy.graph, projectDir, "GOMOD") + if err != nil { + return false, err + } + if ownerGoMod == "" || ownerGoMod == os.DevNull { + return false, nil + } + same, err := sameFile(moduleGoMod, ownerGoMod) + if err != nil { + return false, err + } + if !same { + return false, nil + } + return matchVendorProjects(projectDir, target.Projects(), recursive) +} + +type workspaceVendorMember struct { + modulePath string + root string + goMod string +} + +// probeVendorWorkspacePackage handles XGo-only packages for which go list +// cannot report physical package fields. Only go.work use members are eligible: +// workspace replacements and other live dependency sources are deliberately +// excluded from this conservative vendor-mode classification. +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 := loadRuntimeModule(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 runtime 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 := goEnvWithPolicy(ctx, r.policy.graph, projectDir, "GOMOD") + 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 +} + +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 patternContainsRuntimeProjects(dir, projects) + } + return hasRuntimeProject(dir, projects) +} + +func hasRuntimeProject(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 + } + ext := modfile.ClassExt(entry.Name()) + for _, project := range projects { + if project.Runtime != nil && project.IsProj(ext, entry.Name()) { + return true, nil + } + } + } + return false, nil +} + +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 modulesTextIsForWorkspace. +// A missing modules.txt retains the historical module-vendor behavior, but it +// cannot identify a workspace vendor directory. +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", ErrRuntimeVendorUnsupported, mode) +} diff --git a/cmd/internal/runtimeprovider/resolve_vendor_test.go b/cmd/internal/runtimeprovider/resolve_vendor_test.go new file mode 100644 index 000000000..53bb46cff --- /dev/null +++ b/cmd/internal/runtimeprovider/resolve_vendor_test.go @@ -0,0 +1,484 @@ +/* + * 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 runtimeprovider + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/goplus/mod/modfile" + "github.com/goplus/mod/modload" + "github.com/goplus/xgo/x/xgoprojs" +) + +func TestResolveRuntimeVendorFailsClosed(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newRuntimeFixture(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, ErrRuntimeVendorUnsupported) { + 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, ErrRuntimeVendorUnsupported) { + t.Fatalf("external class vendor target = %v, want ErrRuntimeVendorUnsupported", err) + } + } else if !errors.Is(err, ErrNotHandled) { + t.Fatalf("main-module legacy vendor target = %v, want ErrNotHandled", err) + } + }) + } +} + +func TestResolveRuntimeTargetStillRejectsVendor(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\nruntime v1 example.test/framework/cmd/provider\n") + mustWriteFile(t, filepath.Join(project, "main.foo"), "// runtime 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, ErrRuntimeVendorUnsupported) { + t.Fatalf("runtime 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", "provider")) + 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/provider\"\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 +runtime v1 example.test/framework/cmd/provider +`) + mustWriteFile(t, filepath.Join(framework, "cmd", "provider", "provider.go"), "package provider\n") + mustWriteFile(t, filepath.Join(project, "main.foo"), "// runtime project\n") + mustRunGo(t, app, "off", "mod", "vendor") + vendoredFramework := filepath.Join(app, "vendor", "example.test", "framework") + if _, err := os.Stat(filepath.Join(vendoredFramework, "cmd", "provider", "provider.go")); err != nil { + t.Fatalf("real vendor snapshot omitted imported provider 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, ErrRuntimeVendorUnsupported) { + t.Fatalf("real vendored class target = %v, want ErrRuntimeVendorUnsupported", 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", "provider")) + 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/provider\"\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\nruntime v1 example.test/framework/cmd/provider\n") + mustWriteFile(t, filepath.Join(framework, "cmd", "provider", "provider.go"), "package provider\n") + mustWriteFile(t, filepath.Join(project, "main.foo"), "// runtime 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, ErrRuntimeVendorUnsupported) { + t.Fatalf("workspace vendored class target = %v, want ErrRuntimeVendorUnsupported", err) + } +} + +func TestResolveWorkspaceVendorPackageUsesTargetModuleMetadata(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + root := t.TempDir() + caller := filepath.Join(root, "caller") + runtimeModule := filepath.Join(root, "runtime") + runtimeProject := filepath.Join(runtimeModule, "game") + legacyModule := filepath.Join(root, "legacy") + legacyProject := filepath.Join(legacyModule, "game") + mustMkdirAll(t, caller) + mustMkdirAll(t, runtimeProject) + mustMkdirAll(t, legacyProject) + mustWriteFile(t, filepath.Join(root, "go.work"), `go 1.25 + +use ( + ./caller + ./runtime + ./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(runtimeModule, "go.mod"), "module example.test/runtime\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(runtimeModule, "gox.mod"), "xgo 1.8\nproject main.foo Game example.test/runtime\nruntime v1 example.test/runtime/cmd/provider\n") + mustWriteFile(t, filepath.Join(runtimeProject, "main.foo"), "// runtime 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/runtime/game"}); !errors.Is(err, ErrRuntimeVendorUnsupported) { + t.Fatalf("workspace runtime package in vendor mode = %v, want ErrRuntimeVendorUnsupported", 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 runtime, err := resolver.probeVendorWorkspacePackage(context.Background(), "example.test/runtime/game", false); err != nil || !runtime { + t.Fatalf("workspace fallback runtime classification = %v, %v; want true", runtime, err) + } + if runtime, err := resolver.probeVendorWorkspacePackage(context.Background(), "example.test/legacy/game", false); err != nil || runtime { + t.Fatalf("workspace fallback legacy classification = %v, %v; want false", runtime, 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, ErrRuntimeVendorUnsupported) { + t.Fatalf("workspace external class classification = %v, want ErrRuntimeVendorUnsupported", 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\nruntime v1 example.test/shared/sub/cmd/provider\n") + mustWriteFile(t, filepath.Join(childProject, "main.foo"), "// runtime 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 runtime, err := resolver.probeVendorWorkspacePackage(context.Background(), "example.test/shared/sub/game", false); err != nil || !runtime { + t.Fatalf("longest workspace member classification = %v, %v; want true", runtime, 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\nruntime v1 example.test/app/cmd/provider\n") + mustWriteFile(t, filepath.Join(app, "app.go"), "package app\n") + mustWriteFile(t, filepath.Join(project, "main.foo"), "// runtime 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, ErrRuntimeVendorUnsupported) { + t.Fatalf("recursive vendor target = %v, want ErrRuntimeVendorUnsupported", err) + } + }) + } +} + +func TestVendorRuntimeProbePropagatesFilesystemErrors(t *testing.T) { + projects := []*modfile.Project{{Runtime: &modfile.Runtime{Protocol: "v1", Package: "example.test/provider"}}} + if _, err := hasRuntimeProject(filepath.Join(t.TempDir(), "missing"), projects); err == nil { + t.Fatal("missing project directory was classified as no runtime") + } + + 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) + } +} + +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 TestResolveVendorIgnoresUnmarkedDependencyRuntime(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\nruntime v1 example.test/framework/cmd/provider\n") + mustWriteFile(t, filepath.Join(project, "main.foo"), "// unmarked dependency runtime 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 runtime = %v, want ErrNotHandled", err) + } +} diff --git a/cmd/internal/runtimeprovider/resolve_versioned.go b/cmd/internal/runtimeprovider/resolve_versioned.go new file mode 100644 index 000000000..b513ba04b --- /dev/null +++ b/cmd/internal/runtimeprovider/resolve_versioned.go @@ -0,0 +1,295 @@ +/* + * 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 runtimeprovider + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "golang.org/x/mod/module" +) + +// versionedPackageHasRuntime classifies the requested module version in an +// isolated graph. It must not borrow metadata from the caller's selected build +// list: the same package path may describe a legacy project in one version and +// a runtime project in another. +func (r *Resolver) versionedPackageHasRuntime(ctx context.Context, target string) (bool, error) { + importPath, query, ok := splitVersionedPackageTarget(target) + if !ok { + return false, nil + } + + probeDir, err := os.MkdirTemp("", "xgo-runtime-version-probe-") + if err != nil { + return false, fmt.Errorf("create versioned package graph probe: %w", err) + } + defer os.RemoveAll(probeDir) + probeMod := "module xgo.dev/runtimeprovider/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 { + // A failed go get remains the legacy path's diagnostic responsibility. + // XGo-only packages may not be Go packages at all, so continue with the + // structured module probe below without inferring anything from stderr. + 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/runtimeprovider/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 -m all` may omit source fields for an otherwise selected + // dependency. `go list -find` is authoritative for this package and has + // already supplied a fully materialized module record. + 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 runtime 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.Runtime != 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/runtimeprovider/types.go b/cmd/internal/runtimeprovider/types.go index 2f478791a..1fad47524 100644 --- a/cmd/internal/runtimeprovider/types.go +++ b/cmd/internal/runtimeprovider/types.go @@ -120,10 +120,6 @@ type Streams struct { Stderr io.Writer } -func defaultStreams() Streams { - return Streams{Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr} -} - // ProcessStatus preserves a normal exit code or an operating-system signal. type ProcessStatus struct { Code int From 847176a6a9c0fcdf7602e78f1094290b9150c560 Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Thu, 20 Aug 2026 19:21:04 +0800 Subject: [PATCH 5/7] refactor(runtimeprovider): streamline dispatch and discovery --- cmd/internal/runtimeprovider/dispatch.go | 57 ++++++++----------- cmd/internal/runtimeprovider/flags.go | 11 +--- cmd/internal/runtimeprovider/graph_command.go | 35 ++++-------- cmd/internal/runtimeprovider/graph_overlay.go | 36 ++++-------- cmd/internal/runtimeprovider/graph_package.go | 30 +++++----- cmd/internal/runtimeprovider/output.go | 18 ++---- cmd/internal/runtimeprovider/provider.go | 5 +- .../runtimeprovider/resolve_overlay.go | 26 +++------ .../runtimeprovider/resolve_project.go | 15 +++-- .../runtimeprovider/resolve_vendor.go | 45 +++++---------- .../runtimeprovider/resolve_versioned.go | 14 ++--- cmd/internal/runtimeprovider/types.go | 4 +- doc/gox.mod.md | 21 ++++++- 13 files changed, 128 insertions(+), 189 deletions(-) diff --git a/cmd/internal/runtimeprovider/dispatch.go b/cmd/internal/runtimeprovider/dispatch.go index 516cf8664..96be2ae5d 100644 --- a/cmd/internal/runtimeprovider/dispatch.go +++ b/cmd/internal/runtimeprovider/dispatch.go @@ -36,48 +36,24 @@ type DispatchResult struct { // TryRun resolves and, when matched, runs one runtime 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 target == nil { - return DispatchResult{}, fmt.Errorf("runtime provider run requires a non-nil target") - } - resolver, ctx, err := newDispatchResolver(ctx, cwd, flags) - if err != nil { - return DispatchResult{}, err - } - rt, handled, err := resolveTarget(ctx, resolver, target) - if err != nil || !handled { - return DispatchResult{Handled: handled}, err - } if len(appArgs) != 0 && appArgs[0] == "--" { appArgs = appArgs[1:] } - boundary := beginRuntimeSignalBoundary(ctx) - status, err := resolver.Run(boundary.Context(), rt, appArgs, streams) - return finishDispatch(boundary, DispatchResult{}, status, err) + return dispatchOne(ctx, cwd, target, flags, "run", func(ctx context.Context, resolver *Resolver, rt *Runtime) (ProcessStatus, string, error) { + status, err := resolver.Run(ctx, rt, appArgs, streams) + return status, "", err + }) } // TryBuild resolves and, when matched, builds one runtime 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) { - if target == nil { - return DispatchResult{}, fmt.Errorf("runtime provider build requires a non-nil target") - } - resolver, ctx, err := newDispatchResolver(ctx, cwd, flags) - if err != nil { - return DispatchResult{}, err - } - rt, handled, err := resolveTarget(ctx, resolver, target) - if err != nil || !handled { - return DispatchResult{Handled: handled}, err - } - boundary := beginRuntimeSignalBoundary(ctx) - status, final, err := resolver.Build(boundary.Context(), rt, output, streams) - return finishDispatch(boundary, DispatchResult{Output: final}, status, err) + return dispatchOne(ctx, cwd, target, flags, "build", func(ctx context.Context, resolver *Resolver, rt *Runtime) (ProcessStatus, string, error) { + return resolver.Build(ctx, rt, output, streams) + }) } -// TryInstall resolves every target before starting a provider. A request with -// multiple targets is rejected only when at least one target is runtime-backed; -// an all-legacy request remains available to the existing install path. This -// keeps provider execution and output-directory creation after validation. +// TryInstall resolves all targets before creating output or starting a provider. func TryInstall(ctx context.Context, cwd string, targets []xgoprojs.Proj, flags []string, streams Streams) (DispatchResult, error) { if len(targets) == 0 { return DispatchResult{}, fmt.Errorf("runtime provider install requires at least one target") @@ -112,6 +88,23 @@ func TryInstall(ctx context.Context, cwd string, targets []xgoprojs.Proj, flags 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, *Runtime) (ProcessStatus, string, error)) (DispatchResult, error) { + if target == nil { + return DispatchResult{}, fmt.Errorf("runtime provider %s requires a non-nil target", action) + } + resolver, ctx, err := newDispatchResolver(ctx, cwd, flags) + if err != nil { + return DispatchResult{}, err + } + rt, handled, err := resolveTarget(ctx, resolver, target) + if err != nil || !handled { + return DispatchResult{Handled: handled}, err + } + boundary := beginRuntimeSignalBoundary(ctx) + status, output, err := run(boundary.Context(), resolver, rt) + return finishDispatch(boundary, DispatchResult{Output: output}, status, err) +} + func finishDispatch(boundary *runtimeSignalBoundary, result DispatchResult, status ProcessStatus, err error) (DispatchResult, error) { status, err = boundary.Finish(status, err) if err != nil { diff --git a/cmd/internal/runtimeprovider/flags.go b/cmd/internal/runtimeprovider/flags.go index 16dc3d293..d5d4bc0c2 100644 --- a/cmd/internal/runtimeprovider/flags.go +++ b/cmd/internal/runtimeprovider/flags.go @@ -78,9 +78,7 @@ func (p GraphPolicy) goArgs(command string, args ...string) []string { return append(ret, args...) } -// parseRuntimeFlags extracts the policy needed for discovery. Rejected build -// flags are retained and reported only after a runtime project is matched, so -// ordinary projects keep their existing behavior. +// parseRuntimeFlags extracts discovery policy and defers rejected flags. func parseRuntimeFlags(projectDir, goCommand, goWork, ambient string, cli []string) (parsedFlags, error) { ambientArgs, err := splitQuotedFields(ambient) if err != nil { @@ -123,8 +121,7 @@ func parseRuntimeFlags(projectDir, goCommand, goWork, ambient string, cli []stri ret.graph.ModFile = path } else { ret.graph.Overlay = path - // Retain the overlay for authoritative target discovery, then - // reject it only if that target selects a runtime provider. + // Discover through the overlay; reject it for matched runtimes. ret.rejected = append(ret.rejected, arg) } case "v": @@ -207,9 +204,7 @@ func isQuotedFieldSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' } -// splitQuotedFields mirrors cmd/internal/quoted.Split, which is what the Go -// command uses for GOFLAGS. Quotes only delimit a field when they are its first -// byte, and quoted contents are not unescaped. +// splitQuotedFields mirrors the Go command's GOFLAGS parser. func splitQuotedFields(s string) ([]string, error) { var fields []string for len(s) > 0 { diff --git a/cmd/internal/runtimeprovider/graph_command.go b/cmd/internal/runtimeprovider/graph_command.go index d1014005d..e5e30e227 100644 --- a/cmd/internal/runtimeprovider/graph_command.go +++ b/cmd/internal/runtimeprovider/graph_command.go @@ -22,7 +22,6 @@ import ( "fmt" "os" "os/exec" - "path/filepath" "strings" ) @@ -81,10 +80,7 @@ func preparePolicies(ctx context.Context, cwd string, cli []string) (parsedFlags return policy, nil } -// sanitizeGraphFlags removes graph files that do not exist from discovery. -// They remain rejected runtime flags, so a matched runtime still reports the -// policy error through BuildPolicy while a legacy target can continue with its -// normal command path. +// sanitizeGraphFlags defers missing graph files as runtime-only policy errors. func sanitizeGraphFlags(policy parsedFlags) (parsedFlags, error) { if path := policy.graph.ModFile; path != "" { if _, err := os.Stat(path); os.IsNotExist(err) { @@ -110,21 +106,11 @@ func hostGoCommand() (string, error) { if err != nil { return "", fmt.Errorf("host Go command: %w", err) } - path, err = filepath.Abs(path) + canonical, err := canonicalExistingFile(path) if err != nil { - return "", err + return "", fmt.Errorf("host Go command %q: %w", path, err) } - if real, err := filepath.EvalSymlinks(path); err == nil { - path = real - } - info, err := os.Stat(path) - if err != nil { - return "", err - } - if !info.Mode().IsRegular() { - return "", fmt.Errorf("host Go command %q is not a regular file", path) - } - return path, nil + return canonical, nil } func goEnvValue(ctx context.Context, goCommand, dir, key string, clearGOFLAGS bool) (string, error) { @@ -136,17 +122,16 @@ func goEnvValue(ctx context.Context, goCommand, dir, key string, clearGOFLAGS bo if clearGOFLAGS { cmd.Env = replaceEnv(cmd.Env, "GOFLAGS", "") } - out, err := cmd.Output() - if err != nil { - return "", commandError("go env "+key, err, string(cmdStderr(cmd))) - } - return strings.TrimSpace(string(out)), nil + return runGoEnv(cmd, key) } func goEnvWithPolicy(ctx context.Context, policy GraphPolicy, dir, key string) (string, error) { cmd := graphCommand(ctx, policy, dir, "env", key) - // Graph flags do not affect go env values and are deliberately never - // reconstructed into GOFLAGS; all graph operations pass them via argv. + // Graph flags stay in argv; GOFLAGS remains cleared by graphCommand. + return runGoEnv(cmd, key) +} + +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))) diff --git a/cmd/internal/runtimeprovider/graph_overlay.go b/cmd/internal/runtimeprovider/graph_overlay.go index 2958477b2..1e0cca324 100644 --- a/cmd/internal/runtimeprovider/graph_overlay.go +++ b/cmd/internal/runtimeprovider/graph_overlay.go @@ -25,9 +25,8 @@ import ( "strings" ) -// graphFileView keeps the logical paths changed by -overlay. Runtime-provider -// v1 never executes against this view: it uses the map to classify project -// files, while the Go command remains authoritative for graph resolution. +// graphFileView models -overlay paths for classification only. +// The Go command remains authoritative for graph resolution. type graphFileView struct { workDir string replacements map[string]string @@ -125,18 +124,14 @@ func (v *graphFileView) readFile(path string) ([]byte, error) { return os.ReadFile(logical) } -// hasReplacementAncestor reports whether a path is below an exact overlay -// entry. Both a deleted parent and a parent replaced by a regular file hide -// children. Callers must check an exact entry first, because an exact child -// replacement takes precedence over a deleted parent. +// hasReplacementAncestor reports whether an overlay entry hides path. +// Callers check exact replacements first. func (v *graphFileView) hasReplacementAncestor(path string) bool { _, ok := v.replacementAncestor(path) return ok } -// replacementAncestor returns the nearest exact overlay entry above path. -// Nearest-entry selection matches cmd/go's path-prefix lookup and matters for -// nested deletion/addition overlays. +// replacementAncestor returns the nearest overlay entry above path. func (v *graphFileView) replacementAncestor(path string) (string, bool) { if v == nil || !v.hasOverlay() { return "", false @@ -152,9 +147,8 @@ func (v *graphFileView) replacementAncestor(path string) (string, bool) { return "", false } -// hasReplacementChild reports whether a non-deleted overlay key is below path. -// It both preserves physical files below deletions and synthesizes virtual -// directories; replacement destinations stay opaque until Go reads them. +// 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 @@ -203,8 +197,7 @@ func (v *graphFileView) physicalEntries(dir string) ([]os.DirEntry, error) { return entries, err } -// regularFileNames merges physical entries with overlay keys, but never stats -// a replacement destination. This keeps ordinary legacy overlays unchanged. +// 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) @@ -252,8 +245,7 @@ func (v *graphFileView) regularFileNames(dir string) ([]string, error) { return names, nil } -// directoryNames returns physical child directories plus the first component -// of each non-deleted overlay key. It is used only by pattern classification. +// 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) @@ -338,9 +330,7 @@ func (v *graphFileView) regularFileVisible(path string) (bool, error) { return statVisible(logical, os.FileMode.IsRegular) } -// canonicalFile pins a physical file in the usual case, but keeps the logical -// path for an overlay-only file. The latter is necessary because cmd/go can -// create go.mod (or an alternate modfile) solely through -overlay. +// 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) @@ -358,10 +348,8 @@ func (v *graphFileView) canonicalFile(path string) (string, error) { return canonicalExistingFile(logical) } -// canonicalDir is the directory counterpart to canonicalFile. A synthetic -// directory has no filesystem inode, so its clean logical path is returned; -// this is safe for classification because provider execution is rejected for -// overlay-backed runtime targets before any path is sent over the protocol. +// canonicalDir is the directory counterpart to canonicalFile. +// Synthetic directories are safe because overlay runtime targets never execute. func (v *graphFileView) canonicalDir(path string) (string, error) { if !v.hasOverlay() { return canonicalExistingDir(path) diff --git a/cmd/internal/runtimeprovider/graph_package.go b/cmd/internal/runtimeprovider/graph_package.go index 358fdd953..8eb207ec9 100644 --- a/cmd/internal/runtimeprovider/graph_package.go +++ b/cmd/internal/runtimeprovider/graph_package.go @@ -48,9 +48,7 @@ func resolvePackageDirectory(ctx context.Context, graph *effectiveGraph, importP return "", ResolvedModule{}, fmt.Errorf("package target %q resolved as %q", importPath, pkg.ImportPath) } if pkg.Dir == "" || pkg.Module == nil { - // The Go command may omit physical fields for an XGo-only package. - // Resolve that candidate from the selected graph, then ask Go which - // module root owns it so a prefix match cannot cross a nested module. + // XGo-only packages may omit physical fields; resolve them from the graph. return resolveXGoOnlyPackageDirectory(ctx, graph, importPath, policy) } listed, err := normalizeListedModule(*pkg.Module) @@ -66,9 +64,7 @@ func resolvePackageDirectory(ctx context.Context, graph *effectiveGraph, importP return "", ResolvedModule{}, fmt.Errorf("package target %q: %w", importPath, err) } if module.Effective().Dir == "" { - // Unmarked dependencies are not materialized during runtime discovery. - // Return their authoritative package identity so Resolve can classify - // them as legacy before attempting any runtime metadata access. + // Keep unmarked dependency identity without materializing its source. return dir, module, nil } if !sameResolvedModule(listed, module) { @@ -109,6 +105,17 @@ func validatePackageOwnership(ctx context.Context, policy GraphPolicy, module Re return nil } +func moduleOwnsPackage(ctx context.Context, policy GraphPolicy, moduleGoMod, dir string) (bool, error) { + ownerGoMod, err := goEnvWithPolicy(ctx, policy, dir, "GOMOD") + 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("runtime provider does not support package target %q", importPath) @@ -156,11 +163,7 @@ func resolveXGoOnlyPackageDirectory(ctx context.Context, graph *effectiveGraph, if err := validatePackagePath(module, importPath, dir); err != nil { return "", ResolvedModule{}, err } - // An XGo-only package can be supplied entirely by an overlay, in which - // case its logical directory has no physical cwd for `go env` to enter. - // The effective graph already established the owning module; retain the - // nested-module check for physical directories and use that graph identity - // for synthetic ones. + // Overlay-only packages have no physical cwd; the graph owns their identity. if graph.files != nil && graph.files.hasReplacementChild(dir) { return dir, module, nil } @@ -172,10 +175,7 @@ func resolveXGoOnlyPackageDirectory(ctx context.Context, graph *effectiveGraph, return "", ResolvedModule{}, fmt.Errorf("package target %q is outside the effective module graph", importPath) } -// retargetEffectiveGraph keeps the caller's selected build list and only -// changes which already-resolved module owns the class project. This is -// essential for package targets: running go list from a dependency directory -// would silently switch to that dependency's standalone graph. +// 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 { diff --git a/cmd/internal/runtimeprovider/output.go b/cmd/internal/runtimeprovider/output.go index 45ed407cb..ce9b23e3b 100644 --- a/cmd/internal/runtimeprovider/output.go +++ b/cmd/internal/runtimeprovider/output.go @@ -185,10 +185,8 @@ func (tx *outputTransaction) abort() { _ = tx.parent.Close() } -// commitContext validates and publishes the staged output. Cancellation is -// checked immediately before the rename, which is the transaction's commit -// point; cancellation after that point cannot retract an already-published -// executable. +// 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("runtime output transaction is closed") @@ -265,9 +263,7 @@ func (tx *outputTransaction) commitContext(ctx context.Context) error { } else if err != nil && !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("reinspect runtime output %q: %w", tx.final, err) } - // The provider may run arbitrary build logic for a long time. Refuse to - // publish through the pinned handle if the user-visible parent pathname was - // renamed or replaced while that logic ran. + // Recheck the user-visible parent after provider execution. if err := tx.checkParentPath(); err != nil { return err } @@ -283,13 +279,9 @@ func (tx *outputTransaction) commitContext(ctx context.Context) error { } return fmt.Errorf("commit runtime output (final state %s): %w", state, err) } - // Rename is the commit point. A pathname check after it is diagnostic only: - // the output is already visible and must not be reported as an uncommitted - // transaction. + // Rename is the commit point; later checks are diagnostic only. _ = tx.checkParentPath() - // The rename above is the commit point. Cleanup and directory syncing are - // best effort from here so a successfully published output is never reported - // as a failed transaction. + // Cleanup and directory syncing are best effort after publication. if !tx.keepDir { _ = tx.parent.Remove(tx.workName) } diff --git a/cmd/internal/runtimeprovider/provider.go b/cmd/internal/runtimeprovider/provider.go index 3843d6e99..a3955d964 100644 --- a/cmd/internal/runtimeprovider/provider.go +++ b/cmd/internal/runtimeprovider/provider.go @@ -281,10 +281,7 @@ func processStatus(err error) (ProcessStatus, error) { return status, nil } -// statusUnlessCanceled closes the race where a provider exits successfully at -// the same time its parent context is canceled. A successful build/run must not -// be reported after cancellation, because build callers may publish output -// immediately after receiving that status. +// statusUnlessCanceled rejects a successful provider result after cancellation. func statusUnlessCanceled(ctx context.Context, status ProcessStatus) (ProcessStatus, error) { if !status.Signaled && status.Code == 0 { if cause := context.Cause(ctx); cause != nil { diff --git a/cmd/internal/runtimeprovider/resolve_overlay.go b/cmd/internal/runtimeprovider/resolve_overlay.go index ca1cf5ade..0d72431c3 100644 --- a/cmd/internal/runtimeprovider/resolve_overlay.go +++ b/cmd/internal/runtimeprovider/resolve_overlay.go @@ -27,10 +27,8 @@ import ( "github.com/goplus/mod/modfile" ) -// resolveOverlayLocalTarget disambiguates a local argument that does not exist -// on disk. xgoprojs parses both an overlay-only directory and an overlay-only -// file as DirProj, so classification must consult the same virtual filesystem -// as the Go command before deciding that the target belongs to the legacy path. +// 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 { @@ -103,10 +101,8 @@ func classModuleMarked(classModules []ResolvedModule, modulePath string) bool { return false } -// overlayRuntimeProjectMatch classifies a target using the effective graph's -// overlay-aware metadata. It is intentionally a classification-only path: -// v1 has no snapshot contract for overlays, so a positive match is converted -// to an explicit unsupported error before any provider Runtime is built. +// overlayRuntimeProjectMatch classifies against overlay metadata only. +// Runtime provider v1 rejects a positive overlay match before execution. func overlayRuntimeProjectMatch(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") @@ -126,11 +122,8 @@ func overlayRuntimeProjectMatch(projectDir string, graph *effectiveGraph, recurs return false, err } for _, name := range names { - ext := modfile.ClassExt(name) - for _, project := range projects { - if project.Runtime != nil && project.IsProj(ext, name) { - return true, nil - } + if runtimeProjectMatches(projects, name) { + return true, nil } } return false, nil @@ -168,11 +161,8 @@ func overlayWalkRuntimeProjects(root string, projects []*modfile.Project, view * return err } for _, name := range names { - ext := modfile.ClassExt(name) - for _, project := range projects { - if project.Runtime != nil && project.IsProj(ext, name) { - return errRuntimeProjectInPattern - } + if runtimeProjectMatches(projects, name) { + return errRuntimeProjectInPattern } } dirs, err := view.directoryNames(current) diff --git a/cmd/internal/runtimeprovider/resolve_project.go b/cmd/internal/runtimeprovider/resolve_project.go index df9a3abde..50e5a73d4 100644 --- a/cmd/internal/runtimeprovider/resolve_project.go +++ b/cmd/internal/runtimeprovider/resolve_project.go @@ -38,10 +38,7 @@ func runtimeGuard(projectDir, providerPackage string) string { return sha256Bytes([]byte(projectDir + "\x00" + providerPackage)) } -// loadRuntimeModule preserves modload's gox.mod-to-gop.mod fallback while -// distinguishing an absent optional metadata file from an unreadable one. -// Runtime ownership must not fall back to the legacy path on metadata I/O -// failures. +// loadRuntimeModule preserves metadata fallback and reports unreadable optional files. func loadRuntimeModule(goMod, goxMod string) (modload.Module, error) { return loadRuntimeModuleView(goMod, goxMod, nil) } @@ -74,6 +71,16 @@ func externalClassModule(module modload.Module) string { return "" } +func runtimeProjectMatches(projects []*modfile.Project, name string) bool { + ext := modfile.ClassExt(name) + for _, project := range projects { + if project != nil && project.Runtime != nil && project.IsProj(ext, name) { + return true + } + } + return false +} + func loadResolvedClasses(graph *effectiveGraph) (*xgomod.Module, bool, error) { target := graph.Target.Effective() loaded, err := loadRuntimeModuleView(graph.TargetModFile.Path, filepath.Join(target.Dir, "gox.mod"), graph.files) diff --git a/cmd/internal/runtimeprovider/resolve_vendor.go b/cmd/internal/runtimeprovider/resolve_vendor.go index fb6ce4401..39c568549 100644 --- a/cmd/internal/runtimeprovider/resolve_vendor.go +++ b/cmd/internal/runtimeprovider/resolve_vendor.go @@ -30,10 +30,8 @@ import ( gomodfile "golang.org/x/mod/modfile" ) -// preflightClassMetadataDetails reads only the target module metadata. It is -// intentionally separate from loadEffectiveGraph: go list -m all cannot run -// in vendor mode, and probing a legacy target must not turn that limitation -// into a runtime-provider error before a runtime project has been identified. +// preflightClassMetadataDetails reads target metadata without loading the graph. +// This keeps legacy vendor targets on the existing path. func (r *Resolver) preflightClassMetadataDetails(ctx context.Context, dir string) (loaded modload.Module, moduleGoMod string, hasClass, vendor bool, err error) { goMod, err := goEnvWithPolicy(ctx, r.policy.graph, dir, "GOMOD") if err != nil { @@ -63,18 +61,14 @@ func (r *Resolver) preflightClassMetadataDetails(ctx context.Context, dir string return loaded, moduleGoMod, hasClass, vendor, err } -// probeVendorProject identifies a runtime project without invoking -// "go list -m all". Standard Go vendor snapshots do not preserve gox.mod or -// gop.mod reliably, so an external class marker is indeterminate and must fail -// closed instead of consulting a live replacement or returning ErrNotHandled. +// 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 remains available -// in vendor mode, to distinguish another workspace main module from an -// unmarked dependency. Only main/workspace module metadata is authoritative; -// external class metadata is absent from standard vendor data and fails closed. +// 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) @@ -126,14 +120,7 @@ func (r *Resolver) probeVendorPackageInModule(ctx context.Context, moduleGoMod s if err != nil || !pathWithin(root, projectDir) { return false, nil } - ownerGoMod, err := goEnvWithPolicy(ctx, r.policy.graph, projectDir, "GOMOD") - if err != nil { - return false, err - } - if ownerGoMod == "" || ownerGoMod == os.DevNull { - return false, nil - } - same, err := sameFile(moduleGoMod, ownerGoMod) + same, err := moduleOwnsPackage(ctx, r.policy.graph, moduleGoMod, projectDir) if err != nil { return false, err } @@ -149,10 +136,8 @@ type workspaceVendorMember struct { goMod string } -// probeVendorWorkspacePackage handles XGo-only packages for which go list -// cannot report physical package fields. Only go.work use members are eligible: -// workspace replacements and other live dependency sources are deliberately -// excluded from this conservative vendor-mode classification. +// 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 { @@ -312,11 +297,8 @@ func hasRuntimeProject(dir string, projects []*modfile.Project) (bool, error) { if !info.Mode().IsRegular() { continue } - ext := modfile.ClassExt(entry.Name()) - for _, project := range projects { - if project.Runtime != nil && project.IsProj(ext, entry.Name()) { - return true, nil - } + if runtimeProjectMatches(projects, entry.Name()) { + return true, nil } } return false, nil @@ -370,9 +352,8 @@ func effectiveVendorMode(policy GraphPolicy, moduleGoMod string, parsed *gomodfi return vendoredWorkspace == workspace, nil } -// vendorManifestIsForWorkspace mirrors cmd/go's modulesTextIsForWorkspace. -// A missing modules.txt retains the historical module-vendor behavior, but it -// cannot identify a workspace vendor directory. +// 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) { diff --git a/cmd/internal/runtimeprovider/resolve_versioned.go b/cmd/internal/runtimeprovider/resolve_versioned.go index b513ba04b..f849d153d 100644 --- a/cmd/internal/runtimeprovider/resolve_versioned.go +++ b/cmd/internal/runtimeprovider/resolve_versioned.go @@ -29,10 +29,8 @@ import ( "golang.org/x/mod/module" ) -// versionedPackageHasRuntime classifies the requested module version in an -// isolated graph. It must not borrow metadata from the caller's selected build -// list: the same package path may describe a legacy project in one version and -// a runtime project in another. +// versionedPackageHasRuntime classifies the requested version in an isolated graph. +// Caller graph metadata is deliberately not reused. func (r *Resolver) versionedPackageHasRuntime(ctx context.Context, target string) (bool, error) { importPath, query, ok := splitVersionedPackageTarget(target) if !ok { @@ -56,9 +54,7 @@ func (r *Resolver) versionedPackageHasRuntime(ctx context.Context, target string get.Stdout = io.Discard getErr := get.Run() if getErr != nil { - // A failed go get remains the legacy path's diagnostic responsibility. - // XGo-only packages may not be Go packages at all, so continue with the - // structured module probe below without inferring anything from stderr. + // Continue with the structured probe; XGo-only packages may not be Go packages. if cause := context.Cause(ctx); cause != nil { return false, cause } @@ -127,9 +123,7 @@ func (r *Resolver) versionedPackageHasRuntime(ctx context.Context, target string return false, fmt.Errorf("versioned package %q graph selected unexpected module %#v", target, requested) } if requested.Effective().Dir == "" || requested.Effective().GoMod == "" { - // `go list -m all` may omit source fields for an otherwise selected - // dependency. `go list -find` is authoritative for this package and has - // already supplied a fully materialized module record. + // `go list -find` already supplied the authoritative source fields. requested = listed graph.Modules[listed.Selected.Path] = requested } diff --git a/cmd/internal/runtimeprovider/types.go b/cmd/internal/runtimeprovider/types.go index 1fad47524..6b8054ad4 100644 --- a/cmd/internal/runtimeprovider/types.go +++ b/cmd/internal/runtimeprovider/types.go @@ -14,9 +14,7 @@ * limitations under the License. */ -// Package runtimeprovider implements XGo's private runtime-provider -// dispatcher. The shared argv contract lives in mod/runtimeprotocol so XGo -// and provider implementations cannot maintain divergent codecs. +// Package runtimeprovider implements XGo's private runtime-provider dispatcher. package runtimeprovider import ( diff --git a/doc/gox.mod.md b/doc/gox.mod.md index 838888904..33e7433b6 100644 --- a/doc/gox.mod.md +++ b/doc/gox.mod.md @@ -221,7 +221,26 @@ mygame/ ``` --- - + +## Runtime providers + +A framework may delegate execution to a verified provider executable: + +```text +runtime v1 example.com/framework/cmd/provider +``` + +The provider 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 provider protocol. + +Runtime providers are intentionally fail-closed: vendor and overlay-backed +runtime projects are rejected, and `XGO_RUNTIME=off` disables dispatch. Keep +the provider 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. From d76ed51b59cbfc616994e2e6d3c067b18fc8e0f2 Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Thu, 20 Aug 2026 19:35:57 +0800 Subject: [PATCH 6/7] fix(runtimeprovider): clarify module version floor --- cmd/internal/runtimeprovider/resolve_test.go | 24 +++++++++++++++++++- cmd/internal/runtimeprovider/version.go | 2 +- doc/gox.mod.md | 5 ++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/cmd/internal/runtimeprovider/resolve_test.go b/cmd/internal/runtimeprovider/resolve_test.go index 819800b80..450aa2f9a 100644 --- a/cmd/internal/runtimeprovider/resolve_test.go +++ b/cmd/internal/runtimeprovider/resolve_test.go @@ -607,13 +607,35 @@ func TestCheckRequiredXGoReportsDevelopmentCapability(t *testing.T) { if err == nil { t.Fatal("development build unexpectedly satisfied a newer capability") } - for _, want := range []string{"1.8.1", "(devel)", "runtime-provider capability 1.8.0"} { + for _, want := range []string{"declaring module requires XGo 1.8.1", "(devel)", "runtime-provider 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 := newRuntimeFixture(t) + resolver := fixture.resolver(t) + resolver.xgoVersion = "v1.8.0" + resolveRuntime(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" + resolveRuntime(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) +} + func mustRunGo(t *testing.T, dir, goWork string, args ...string) { t.Helper() cmd := exec.Command("go", args...) diff --git a/cmd/internal/runtimeprovider/version.go b/cmd/internal/runtimeprovider/version.go index 3500b6402..5a0acf0e5 100644 --- a/cmd/internal/runtimeprovider/version.go +++ b/cmd/internal/runtimeprovider/version.go @@ -40,7 +40,7 @@ func checkRequiredXGo(required, current string) error { } currentSemver, ok := comparableRuntimeProviderVersion(current) if !ok || semver.Compare(currentSemver, requiredSemver) < 0 { - return fmt.Errorf("runtime project requires XGo %s, but xgo build is %s", required, describeRuntimeProviderVersion(current)) + return fmt.Errorf("runtime project's declaring module requires XGo %s, but xgo build is %s", required, describeRuntimeProviderVersion(current)) } return nil } diff --git a/doc/gox.mod.md b/doc/gox.mod.md index 33e7433b6..fe3f286d1 100644 --- a/doc/gox.mod.md +++ b/doc/gox.mod.md @@ -235,6 +235,11 @@ 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 provider protocol. +The protocol version and the declaring module's `xgo` requirement are independent. +`runtime v1` identifies the provider 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. + Runtime providers are intentionally fail-closed: vendor and overlay-backed runtime projects are rejected, and `XGO_RUNTIME=off` disables dispatch. Keep the provider package and its `gox.mod`/`gop.mod` in the framework module. From 45dca115237f1c4c03a7aafced8e3d554c4e379a Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Thu, 20 Aug 2026 20:12:03 +0800 Subject: [PATCH 7/7] refactor(driver): rename runtime provider --- cmd/internal/build/build.go | 18 +- cmd/internal/install/install.go | 18 +- .../cli_e2e_test.go | 44 ++--- .../command.go | 2 +- .../dispatch.go | 58 +++--- .../dispatch_test.go | 16 +- .../provider.go => projectdriver/driver.go} | 144 +++++++------- .../driver_test.go} | 60 +++--- .../executable_darwin.go | 12 +- .../executable_linux.go | 4 +- .../executable_other.go | 2 +- .../executable_windows.go | 4 +- .../flags.go | 16 +- .../flags_test.go | 36 ++-- .../graph.go | 6 +- .../graph_command.go | 6 +- .../graph_overlay.go | 4 +- .../graph_package.go | 4 +- .../graph_paths.go | 2 +- .../graph_test.go | 16 +- .../output.go | 74 +++---- .../output_test.go | 2 +- .../process_unix.go | 62 +++--- .../process_unix_test.go | 98 +++++----- .../process_windows.go | 30 +-- .../process_windows_test.go | 28 +-- .../protocol.go | 62 +++--- .../protocol_test.go | 50 ++--- .../resolve.go | 78 ++++---- .../resolve_overlay.go | 30 +-- .../resolve_overlay_test.go | 24 +-- .../resolve_project.go | 58 +++--- .../resolve_test.go | 184 +++++++++--------- .../resolve_vendor.go | 22 +-- .../resolve_vendor_test.go | 116 +++++------ .../resolve_versioned.go | 16 +- .../signal_boundary_unix.go | 20 +- .../signal_boundary_unix_test.go | 12 +- .../signal_boundary_windows.go | 12 +- .../status.go | 6 +- .../status_unix.go | 2 +- .../status_windows.go | 2 +- .../types.go | 40 ++-- .../version.go | 22 +-- cmd/internal/run/run.go | 26 +-- doc/gox.mod.md | 18 +- 46 files changed, 783 insertions(+), 783 deletions(-) rename cmd/internal/{runtimeprovider => projectdriver}/cli_e2e_test.go (83%) rename cmd/internal/{runtimeprovider => projectdriver}/command.go (97%) rename cmd/internal/{runtimeprovider => projectdriver}/dispatch.go (64%) rename cmd/internal/{runtimeprovider => projectdriver}/dispatch_test.go (88%) rename cmd/internal/{runtimeprovider/provider.go => projectdriver/driver.go} (54%) rename cmd/internal/{runtimeprovider/provider_test.go => projectdriver/driver_test.go} (73%) rename cmd/internal/{runtimeprovider => projectdriver}/executable_darwin.go (79%) rename cmd/internal/{runtimeprovider => projectdriver}/executable_linux.go (88%) rename cmd/internal/{runtimeprovider => projectdriver}/executable_other.go (96%) rename cmd/internal/{runtimeprovider => projectdriver}/executable_windows.go (88%) rename cmd/internal/{runtimeprovider => projectdriver}/flags.go (91%) rename cmd/internal/{runtimeprovider => projectdriver}/flags_test.go (77%) rename cmd/internal/{runtimeprovider => projectdriver}/graph.go (98%) rename cmd/internal/{runtimeprovider => projectdriver}/graph_command.go (96%) rename cmd/internal/{runtimeprovider => projectdriver}/graph_overlay.go (98%) rename cmd/internal/{runtimeprovider => projectdriver}/graph_package.go (98%) rename cmd/internal/{runtimeprovider => projectdriver}/graph_paths.go (98%) rename cmd/internal/{runtimeprovider => projectdriver}/graph_test.go (96%) rename cmd/internal/{runtimeprovider => projectdriver}/output.go (72%) rename cmd/internal/{runtimeprovider => projectdriver}/output_test.go (99%) rename cmd/internal/{runtimeprovider => projectdriver}/process_unix.go (55%) rename cmd/internal/{runtimeprovider => projectdriver}/process_unix_test.go (63%) rename cmd/internal/{runtimeprovider => projectdriver}/process_windows.go (82%) rename cmd/internal/{runtimeprovider => projectdriver}/process_windows_test.go (76%) rename cmd/internal/{runtimeprovider => projectdriver}/protocol.go (67%) rename cmd/internal/{runtimeprovider => projectdriver}/protocol_test.go (72%) rename cmd/internal/{runtimeprovider => projectdriver}/resolve.go (78%) rename cmd/internal/{runtimeprovider => projectdriver}/resolve_overlay.go (82%) rename cmd/internal/{runtimeprovider => projectdriver}/resolve_overlay_test.go (89%) rename cmd/internal/{runtimeprovider => projectdriver}/resolve_project.go (78%) rename cmd/internal/{runtimeprovider => projectdriver}/resolve_test.go (78%) rename cmd/internal/{runtimeprovider => projectdriver}/resolve_vendor.go (94%) rename cmd/internal/{runtimeprovider => projectdriver}/resolve_vendor_test.go (80%) rename cmd/internal/{runtimeprovider => projectdriver}/resolve_versioned.go (93%) rename cmd/internal/{runtimeprovider => projectdriver}/signal_boundary_unix.go (76%) rename cmd/internal/{runtimeprovider => projectdriver}/signal_boundary_unix_test.go (74%) rename cmd/internal/{runtimeprovider => projectdriver}/signal_boundary_windows.go (76%) rename cmd/internal/{runtimeprovider => projectdriver}/status.go (81%) rename cmd/internal/{runtimeprovider => projectdriver}/status_unix.go (97%) rename cmd/internal/{runtimeprovider => projectdriver}/status_windows.go (96%) rename cmd/internal/{runtimeprovider => projectdriver}/types.go (67%) rename cmd/internal/{runtimeprovider => projectdriver}/version.go (74%) diff --git a/cmd/internal/build/build.go b/cmd/internal/build/build.go index 6585143c6..fb9088bee 100644 --- a/cmd/internal/build/build.go +++ b/cmd/internal/build/build.go @@ -28,7 +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/runtimeprovider" + "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,14 +75,14 @@ func runCmd(cmd *base.Command, args []string) { if len(args) != 0 { log.Panicln("too many arguments:", args) } - runtimeResult, runtimeErr := tryRuntime(proj, pass.Args, *flagOutput) - if runtimeErr != nil { - fmt.Fprintln(os.Stderr, runtimeErr) + driverResult, driverErr := tryDriver(proj, pass.Args, *flagOutput) + if driverErr != nil { + fmt.Fprintln(os.Stderr, driverErr) os.Exit(1) } - if runtimeResult.Handled { - if runtimeResult.Status.Signaled || runtimeResult.Status.Code != 0 { - runtimeprovider.Exit(runtimeResult.Status) + if driverResult.Handled { + if driverResult.Status.Signaled || driverResult.Status.Code != 0 { + projectdriver.Exit(driverResult.Status) } return } @@ -105,8 +105,8 @@ func runCmd(cmd *base.Command, args []string) { build(proj, conf, confCmd) } -func tryRuntime(proj xgoprojs.Proj, flags []string, output string) (runtimeprovider.DispatchResult, error) { - return runtimeprovider.TryBuild(context.Background(), "", proj, flags, output, runtimeprovider.Streams{}) +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) { diff --git a/cmd/internal/install/install.go b/cmd/internal/install/install.go index 7f5c58eb9..b6ed4de6d 100644 --- a/cmd/internal/install/install.go +++ b/cmd/internal/install/install.go @@ -28,7 +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/runtimeprovider" + "github.com/goplus/xgo/cmd/internal/projectdriver" "github.com/goplus/xgo/tool" "github.com/goplus/xgo/x/gocmd" "github.com/goplus/xgo/x/xgoprojs" @@ -72,14 +72,14 @@ func runCmd(cmd *base.Command, args []string) { cl.SetDebug(cl.DbgFlagAll) cl.SetDisableRecover(true) } - runtimeResult, runtimeErr := tryRuntime(projs, pass.Args) - if runtimeErr != nil { - fmt.Fprintln(os.Stderr, runtimeErr) + driverResult, driverErr := tryDriver(projs, pass.Args) + if driverErr != nil { + fmt.Fprintln(os.Stderr, driverErr) os.Exit(1) } - if runtimeResult.Handled { - if runtimeResult.Status.Signaled || runtimeResult.Status.Code != 0 { - runtimeprovider.Exit(runtimeResult.Status) + if driverResult.Handled { + if driverResult.Status.Signaled || driverResult.Status.Code != 0 { + projectdriver.Exit(driverResult.Status) } return } @@ -97,8 +97,8 @@ func runCmd(cmd *base.Command, args []string) { } } -func tryRuntime(projs []xgoprojs.Proj, flags []string) (runtimeprovider.DispatchResult, error) { - return runtimeprovider.TryInstall(context.Background(), "", projs, flags, runtimeprovider.Streams{}) +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) { diff --git a/cmd/internal/runtimeprovider/cli_e2e_test.go b/cmd/internal/projectdriver/cli_e2e_test.go similarity index 83% rename from cmd/internal/runtimeprovider/cli_e2e_test.go rename to cmd/internal/projectdriver/cli_e2e_test.go index ba733f14c..3f619a151 100644 --- a/cmd/internal/runtimeprovider/cli_e2e_test.go +++ b/cmd/internal/projectdriver/cli_e2e_test.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "os" @@ -27,12 +27,12 @@ import ( func TestCLIEndToEnd(t *testing.T) { if testing.Short() { - t.Skip("builds the XGo command and fake provider") + t.Skip("builds the XGo command and fake driver") } buildGoWork := os.Getenv("GOWORK") modDir := protocolModuleDir(t, buildGoWork) - fixture := newRuntimeFixture(t) - configureSharedProtocolProvider(t, fixture, modDir) + fixture := newDriverFixture(t) + configureSharedProtocolDriver(t, fixture, modDir) goxmod := filepath.Join(fixture.framework, "gox.mod") metadata, err := os.ReadFile(goxmod) if err != nil { @@ -42,7 +42,7 @@ func TestCLIEndToEnd(t *testing.T) { if err := os.WriteFile(goxmod, metadata, 0644); err != nil { t.Fatal(err) } - t.Setenv("FAKE_PROVIDER_EXPECT_NO_PACK", "1") + t.Setenv("FAKE_DRIVER_EXPECT_NO_PACK", "1") repo, err := filepath.Abs(filepath.Join("..", "..", "..")) if err != nil { t.Fatal(err) @@ -86,16 +86,16 @@ func TestCLIEndToEnd(t *testing.T) { t.Fatalf("installed artifact = %#v, %v", info, err) } - marker := filepath.Join(fixture.root, "provider-started") + 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_PROVIDER_MARKER="+marker) + 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 runtime install = %v\n%s", err, output) + t.Fatalf("multi driver install = %v\n%s", err, output) } if _, err := os.Stat(marker); !os.IsNotExist(err) { - t.Fatalf("provider ran before multi-target rejection: %v", 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) @@ -126,9 +126,9 @@ func protocolModuleDir(t *testing.T, goWork string) string { return canonicalDir(t, dir) } -func configureSharedProtocolProvider(t *testing.T, fixture runtimeFixture, modDir string) { +func configureSharedProtocolDriver(t *testing.T, fixture driverFixture, modDir string) { t.Helper() - mustWriteFile(t, filepath.Join(fixture.framework, "cmd", "provider", "main.go"), protocolFakeProviderSource) + 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 { @@ -151,7 +151,7 @@ func configureSharedProtocolProvider(t *testing.T, fixture runtimeFixture, modDi download.Dir = fixture.app download.Env = replaceEnv(os.Environ(), "GOWORK", "off") if output, err := download.CombinedOutput(); err != nil { - t.Fatalf("prepare shared-protocol provider graph: %v\n%s", err, output) + t.Fatalf("prepare shared-protocol driver graph: %v\n%s", err, output) } goMod, err = os.ReadFile(goModPath) if err != nil { @@ -160,12 +160,12 @@ func configureSharedProtocolProvider(t *testing.T, fixture runtimeFixture, modDi 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, "runtimeprotocol_tools.go")); !os.IsNotExist(err) { + 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 protocolFakeProviderSource = `package main +const protocolFakeDriverSource = `package main import ( "fmt" @@ -175,29 +175,29 @@ import ( "strconv" "strings" - "github.com/goplus/mod/runtimeprotocol" + "github.com/goplus/mod/driverprotocol" ) func main() { - if marker := os.Getenv("FAKE_PROVIDER_MARKER"); marker != "" { + if marker := os.Getenv("FAKE_DRIVER_MARKER"); marker != "" { _ = os.WriteFile(marker, []byte("started"), 0600) } - if value := os.Getenv("FAKE_PROVIDER_EXIT"); value != "" { + if value := os.Getenv("FAKE_DRIVER_EXIT"); value != "" { code, _ := strconv.Atoi(value) os.Exit(code) } - request, err := runtimeprotocol.Parse(os.Args[1:]) + request, err := driverprotocol.Parse(os.Args[1:]) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(90) } - if os.Getenv("FAKE_PROVIDER_EXPECT_NO_PACK") != "" && request.Project.Pack != nil { + if os.Getenv("FAKE_DRIVER_EXPECT_NO_PACK") != "" && request.Project.Pack != nil { os.Exit(94) } switch request.Action { - case runtimeprotocol.ActionRun: + case driverprotocol.ActionRun: fmt.Printf("run-args=%s\n", strings.Join(request.ApplicationArgs, "|")) - case runtimeprotocol.ActionBuild: + case driverprotocol.ActionBuild: if request.Output == nil { os.Exit(91) } @@ -228,6 +228,6 @@ func cliCommand(xgo, xgoRoot, dir string, args ...string) *exec.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("runtime path touched xgo_autogen.go: %v", err) + t.Fatalf("driver path touched xgo_autogen.go: %v", err) } } diff --git a/cmd/internal/runtimeprovider/command.go b/cmd/internal/projectdriver/command.go similarity index 97% rename from cmd/internal/runtimeprovider/command.go rename to cmd/internal/projectdriver/command.go index 74f1a4c69..c99a7441d 100644 --- a/cmd/internal/runtimeprovider/command.go +++ b/cmd/internal/projectdriver/command.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "bytes" diff --git a/cmd/internal/runtimeprovider/dispatch.go b/cmd/internal/projectdriver/dispatch.go similarity index 64% rename from cmd/internal/runtimeprovider/dispatch.go rename to cmd/internal/projectdriver/dispatch.go index 96be2ae5d..a62a7ec7e 100644 --- a/cmd/internal/runtimeprovider/dispatch.go +++ b/cmd/internal/projectdriver/dispatch.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "context" @@ -25,87 +25,87 @@ import ( "github.com/goplus/xgo/x/xgoprojs" ) -// DispatchResult reports whether a runtime target was selected and, if so, -// the provider process status and build output path. +// 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 runtime target. It never exits +// 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, rt *Runtime) (ProcessStatus, string, error) { - status, err := resolver.Run(ctx, rt, appArgs, streams) + 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 runtime target. It never +// 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, rt *Runtime) (ProcessStatus, string, error) { - return resolver.Build(ctx, rt, output, streams) + 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 provider. +// 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("runtime provider install requires at least one target") + return DispatchResult{}, fmt.Errorf("driver install requires at least one target") } for _, target := range targets { if target == nil { - return DispatchResult{}, fmt.Errorf("runtime provider install requires non-nil targets") + return DispatchResult{}, fmt.Errorf("driver install requires non-nil targets") } } resolver, ctx, err := newDispatchResolver(ctx, cwd, flags) if err != nil { return DispatchResult{}, err } - runtimes := make([]*Runtime, 0, len(targets)) + drivers := make([]*Driver, 0, len(targets)) for _, target := range targets { - rt, handled, err := resolveTarget(ctx, resolver, target) + drv, handled, err := resolveTarget(ctx, resolver, target) if err != nil { return DispatchResult{}, err } if handled { - runtimes = append(runtimes, rt) + drivers = append(drivers, drv) } } - if len(runtimes) == 0 { + if len(drivers) == 0 { return DispatchResult{}, nil } if len(targets) != 1 { - return DispatchResult{}, fmt.Errorf("runtime provider v1 install accepts exactly one target") + return DispatchResult{}, fmt.Errorf("driver v1 install accepts exactly one target") } - boundary := beginRuntimeSignalBoundary(ctx) - status, final, err := resolver.Install(boundary.Context(), runtimes[0], streams) + 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, *Runtime) (ProcessStatus, string, error)) (DispatchResult, error) { +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("runtime provider %s requires a non-nil target", action) + 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 } - rt, handled, err := resolveTarget(ctx, resolver, target) + drv, handled, err := resolveTarget(ctx, resolver, target) if err != nil || !handled { return DispatchResult{Handled: handled}, err } - boundary := beginRuntimeSignalBoundary(ctx) - status, output, err := run(boundary.Context(), resolver, rt) + boundary := beginDriverSignalBoundary(ctx) + status, output, err := run(boundary.Context(), resolver, drv) return finishDispatch(boundary, DispatchResult{Output: output}, status, err) } -func finishDispatch(boundary *runtimeSignalBoundary, result DispatchResult, status ProcessStatus, err error) (DispatchResult, error) { +func finishDispatch(boundary *driverSignalBoundary, result DispatchResult, status ProcessStatus, err error) (DispatchResult, error) { status, err = boundary.Finish(status, err) if err != nil { return DispatchResult{}, err @@ -133,16 +133,16 @@ func newDispatchResolver(ctx context.Context, cwd string, flags []string) (*Reso return resolver, ctx, nil } -func resolveTarget(ctx context.Context, resolver *Resolver, target xgoprojs.Proj) (*Runtime, bool, error) { +func resolveTarget(ctx context.Context, resolver *Resolver, target xgoprojs.Proj) (*Driver, bool, error) { if target == nil { - return nil, false, fmt.Errorf("runtime provider dispatch requires a non-nil target") + return nil, false, fmt.Errorf("driver dispatch requires a non-nil target") } - rt, err := resolver.Resolve(ctx, target) + drv, err := resolver.Resolve(ctx, target) if errors.Is(err, ErrNotHandled) { return nil, false, nil } if err != nil { return nil, false, err } - return rt, true, nil + return drv, true, nil } diff --git a/cmd/internal/runtimeprovider/dispatch_test.go b/cmd/internal/projectdriver/dispatch_test.go similarity index 88% rename from cmd/internal/runtimeprovider/dispatch_test.go rename to cmd/internal/projectdriver/dispatch_test.go index d97fbea13..7856ccf28 100644 --- a/cmd/internal/runtimeprovider/dispatch_test.go +++ b/cmd/internal/projectdriver/dispatch_test.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "bytes" @@ -46,7 +46,7 @@ func TestTryBuildKeepWorkAllowsNilStderr(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } - fixture := newRuntimeFixture(t) + fixture := newDriverFixture(t) setFixtureRequiredXGo(t, fixture, "1.0") output := filepath.Join(fixture.root, "game") var stdout bytes.Buffer @@ -62,17 +62,17 @@ func TestTryBuildKeepWorkAllowsNilStderr(t *testing.T) { } } -func TestDispatchRejectsMixedTargetsBeforeProvider(t *testing.T) { +func TestDispatchRejectsMixedTargetsBeforeDriver(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } - fixture := newRuntimeFixture(t) + 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, "provider-started") - t.Setenv("FAKE_PROVIDER_MARKER", marker) + 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) @@ -81,11 +81,11 @@ func TestDispatchRejectsMixedTargetsBeforeProvider(t *testing.T) { t.Fatalf("mixed dispatch was handled: %#v", result) } if _, err := os.Stat(marker); !os.IsNotExist(err) { - t.Fatalf("provider started before target validation: %v", err) + t.Fatalf("driver started before target validation: %v", err) } } -func setFixtureRequiredXGo(t *testing.T, fixture runtimeFixture, version string) { +func setFixtureRequiredXGo(t *testing.T, fixture driverFixture, version string) { t.Helper() goxmodPath := filepath.Join(fixture.framework, "gox.mod") goxmod, err := os.ReadFile(goxmodPath) diff --git a/cmd/internal/runtimeprovider/provider.go b/cmd/internal/projectdriver/driver.go similarity index 54% rename from cmd/internal/runtimeprovider/provider.go rename to cmd/internal/projectdriver/driver.go index a3955d964..6245c2943 100644 --- a/cmd/internal/runtimeprovider/provider.go +++ b/cmd/internal/projectdriver/driver.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "bytes" @@ -28,31 +28,31 @@ import ( "runtime" ) -type builtProvider struct { +type builtDriver struct { path string dir string keep bool } -func (r *Resolver) Run(ctx context.Context, rt *Runtime, appArgs []string, streams Streams) (ProcessStatus, error) { +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, rt, actionRun, policy, "", "", appArgs, streams) + return execute(ctx, drv, actionRun, policy, "", "", appArgs, streams) } -func (r *Resolver) Build(ctx context.Context, rt *Runtime, requestedOutput string, streams Streams) (ProcessStatus, string, error) { +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, rt, requestedOutput, policy, streams) + return r.buildWithPolicy(ctx, drv, requestedOutput, policy, streams) } -func (r *Resolver) buildWithPolicy(ctx context.Context, rt *Runtime, requestedOutput string, policy BuildPolicy, streams Streams) (ProcessStatus, string, error) { +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, rt.DefaultExecName) + final, err := resolveBuildOutput(r.cwd, requestedOutput, drv.DefaultExecName) if err != nil { return ProcessStatus{}, "", err } @@ -61,7 +61,7 @@ func (r *Resolver) buildWithPolicy(ctx context.Context, rt *Runtime, requestedOu return ProcessStatus{}, final, err } defer tx.abort() - status, err := execute(ctx, rt, actionBuild, policy, tx.staged, final, nil, streams) + status, err := execute(ctx, drv, actionBuild, policy, tx.staged, final, nil, streams) if err != nil || status.Signaled || status.Code != 0 { return status, final, err } @@ -69,25 +69,25 @@ func (r *Resolver) buildWithPolicy(ctx context.Context, rt *Runtime, requestedOu return ProcessStatus{}, final, err } if policy.KeepWork { - fmt.Fprintf(streams.Stderr, "XGO_RUNTIME_OUTPUT_WORK=%s\n", tx.dir) + fmt.Fprintf(streams.Stderr, "XGO_DRIVER_OUTPUT_WORK=%s\n", tx.dir) } return successStatus(), final, nil } -// Install builds one runtime target transactionally into the effective GOBIN. -func (r *Resolver) Install(ctx context.Context, rt *Runtime, streams Streams) (ProcessStatus, string, error) { +// 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, rt.Graph) + 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, rt, filepath.Join(bin, rt.DefaultExecName), policy, streams) + return r.buildWithPolicy(ctx, drv, filepath.Join(bin, drv.DefaultExecName), policy, streams) } func installBin(ctx context.Context, graph GraphPolicy) (string, error) { @@ -116,42 +116,42 @@ func installBin(ctx context.Context, graph GraphPolicy) (string, error) { return filepath.Clean(bin), nil } -func execute(ctx context.Context, rt *Runtime, act action, policy BuildPolicy, output, finalOutput string, appArgs []string, streams Streams) (ProcessStatus, error) { +func execute(ctx context.Context, drv *Driver, act action, policy BuildPolicy, output, finalOutput string, appArgs []string, streams Streams) (ProcessStatus, error) { streams = fillStreams(streams) - provider, err := buildProvider(ctx, rt, policy, streams) + driver, err := buildDriver(ctx, drv, policy, streams) if err != nil { return ProcessStatus{}, err } - defer provider.cleanup() - args, err := providerArgs(rt, act, policy, output, finalOutput, appArgs) + defer driver.cleanup() + args, err := driverArgs(drv, act, policy, output, finalOutput, appArgs) if err != nil { return ProcessStatus{}, err } - env := providerEnvironment(os.Environ(), rt) - if err := validateArgv(provider.path, args, env); err != nil { + 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(provider.path, args)) + fmt.Fprintln(streams.Stderr, redactCommand(driver.path, args)) } - cmd := exec.Command(provider.path, args...) - cmd.Dir = rt.ProjectDir + 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 runProviderProcess(ctx, cmd) + return runDriverProcess(ctx, cmd) } -func buildProvider(ctx context.Context, rt *Runtime, policy BuildPolicy, streams Streams) (*builtProvider, error) { +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("runtime providers are host-only: GOOS=%s, host=%s", 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("runtime providers are host-only: GOARCH=%s, host=%s", goarch, runtime.GOARCH) + return nil, fmt.Errorf("drivers are host-only: GOARCH=%s, host=%s", goarch, runtime.GOARCH) } - if err := validateProvider(ctx, rt); err != nil { + if err := validateDriver(ctx, drv); err != nil { return nil, err } - dir, err := os.MkdirTemp("", "xgo-runtime-provider-") + dir, err := os.MkdirTemp("", "xgo-driver-") if err != nil { return nil, err } @@ -159,84 +159,84 @@ func buildProvider(ctx context.Context, rt *Runtime, policy BuildPolicy, streams _ = os.RemoveAll(dir) return nil, err } - provider := &builtProvider{dir: dir, path: filepath.Join(dir, executableName("provider")), keep: policy.KeepWork} - args := rt.Graph.goArgs("build") + 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", provider.path, rt.ProviderPackage) - cmd := exec.CommandContext(ctx, rt.Graph.GoCommand, args...) - cmd.Dir = rt.Graph.WorkDir - cmd.Env = hostBuildEnvironment(os.Environ(), rt.Graph.GoWork) + 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(rt.Graph.GoCommand, args)) + fmt.Fprintln(streams.Stderr, redactCommand(drv.Graph.GoCommand, args)) } if err := cmd.Run(); err != nil { - provider.cleanup() - return nil, fmt.Errorf("build runtime provider %q: %w", rt.ProviderPackage, err) + driver.cleanup() + return nil, fmt.Errorf("build driver %q: %w", drv.DriverPackage, err) } - info, err := os.Lstat(provider.path) + info, err := os.Lstat(driver.path) if err != nil { - provider.cleanup() - return nil, fmt.Errorf("runtime provider build output: %w", err) + driver.cleanup() + return nil, fmt.Errorf("driver build output: %w", err) } if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() == 0 { - provider.cleanup() - return nil, fmt.Errorf("runtime provider build did not produce a regular executable") + driver.cleanup() + return nil, fmt.Errorf("driver build did not produce a regular executable") } if policy.KeepWork { - fmt.Fprintf(streams.Stderr, "XGO_RUNTIME_PROVIDER_WORK=%s\n", dir) + fmt.Fprintf(streams.Stderr, "XGO_DRIVER_WORK=%s\n", dir) } - return provider, nil + return driver, nil } -func (p *builtProvider) cleanup() { +func (p *builtDriver) cleanup() { if p != nil && !p.keep { _ = os.RemoveAll(p.dir) } } -func validateProvider(ctx context.Context, rt *Runtime) error { - if !moduleContainsPackage(rt.Origin.Selected.Path, rt.ProviderPackage) { - return fmt.Errorf("runtime provider package %q is outside declaring module %q", rt.ProviderPackage, rt.Origin.Selected.Path) - } - args := rt.Graph.goArgs("list", "-json", rt.ProviderPackage) - cmd := exec.CommandContext(ctx, rt.Graph.GoCommand, args...) - cmd.Dir = rt.Graph.WorkDir - cmd.Env = graphEnvironment(os.Environ(), rt.Graph.GoWork) +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 := exec.CommandContext(ctx, drv.Graph.GoCommand, args...) + cmd.Dir = drv.Graph.WorkDir + cmd.Env = graphEnvironment(os.Environ(), drv.Graph.GoWork) var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr if err := cmd.Run(); err != nil { - return commandError("validate runtime provider", err, stderr.String()) + return commandError("validate driver", err, stderr.String()) } var pkg goListPackage if err := json.Unmarshal(stdout.Bytes(), &pkg); err != nil { - return fmt.Errorf("decode runtime provider package: %w", err) + return fmt.Errorf("decode driver package: %w", err) } if pkg.Error != nil && pkg.Error.Err != "" { - return fmt.Errorf("runtime provider package: %s", pkg.Error.Err) + return fmt.Errorf("driver package: %s", pkg.Error.Err) } - if pkg.ImportPath != rt.ProviderPackage { - return fmt.Errorf("runtime provider resolved as %q, want %q", pkg.ImportPath, rt.ProviderPackage) + 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("runtime provider package %q is %q, want command package main", rt.ProviderPackage, pkg.Name) + return fmt.Errorf("driver package %q is %q, want command package main", drv.DriverPackage, pkg.Name) } if pkg.Module == nil { - return fmt.Errorf("runtime provider package %q has no module provenance", rt.ProviderPackage) + return fmt.Errorf("driver package %q has no module provenance", drv.DriverPackage) } module, err := normalizeListedModule(*pkg.Module) if err != nil { return err } - if !sameResolvedModule(module, rt.Origin) { - return fmt.Errorf("runtime provider package %q does not match declaring module provenance", rt.ProviderPackage) + if !sameResolvedModule(module, drv.Origin) { + return fmt.Errorf("driver package %q does not match declaring module provenance", drv.DriverPackage) } - providerDir, err := canonicalExistingDir(pkg.Dir) + driverDir, err := canonicalExistingDir(pkg.Dir) if err != nil { - return fmt.Errorf("runtime provider directory: %w", err) + return fmt.Errorf("driver directory: %w", err) } - if !pathWithin(rt.Origin.Effective().Dir, providerDir) { - return fmt.Errorf("runtime provider directory escapes declaring module") + if !pathWithin(drv.Origin.Effective().Dir, driverDir) { + return fmt.Errorf("driver directory escapes declaring module") } return nil } @@ -248,9 +248,9 @@ func hostBuildEnvironment(base []string, goWork string) []string { return env } -func providerEnvironment(base []string, rt *Runtime) []string { - env := hostBuildEnvironment(base, rt.Graph.GoWork) - return replaceEnv(env, runtimeGuardEnv, runtimeGuard(rt.ProjectDir, rt.ProviderPackage)) +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 { @@ -281,7 +281,7 @@ func processStatus(err error) (ProcessStatus, error) { return status, nil } -// statusUnlessCanceled rejects a successful provider result after cancellation. +// statusUnlessCanceled rejects a successful driver result after cancellation. func statusUnlessCanceled(ctx context.Context, status ProcessStatus) (ProcessStatus, error) { if !status.Signaled && status.Code == 0 { if cause := context.Cause(ctx); cause != nil { @@ -291,7 +291,7 @@ func statusUnlessCanceled(ctx context.Context, status ProcessStatus) (ProcessSta return status, nil } -func providerExitStatus(ctx context.Context, waitErr error) (ProcessStatus, error) { +func driverExitStatus(ctx context.Context, waitErr error) (ProcessStatus, error) { status, err := processStatus(waitErr) if err != nil { return ProcessStatus{}, err diff --git a/cmd/internal/runtimeprovider/provider_test.go b/cmd/internal/projectdriver/driver_test.go similarity index 73% rename from cmd/internal/runtimeprovider/provider_test.go rename to cmd/internal/projectdriver/driver_test.go index a8340d784..5fd252652 100644 --- a/cmd/internal/runtimeprovider/provider_test.go +++ b/cmd/internal/projectdriver/driver_test.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "bytes" @@ -30,15 +30,15 @@ import ( "github.com/goplus/xgo/x/xgoprojs" ) -func TestRuntimeRunAndBuild(t *testing.T) { +func TestDriverRunAndBuild(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } - fixture := newRuntimeFixture(t) + fixture := newDriverFixture(t) resolver := fixture.resolver(t, "-trimpath=true", "-buildvcs=false") - rt := resolveRuntime(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) + drv := resolveDriver(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) var stdout, stderr bytes.Buffer - status, err := resolver.Run(context.Background(), rt, []string{"", "a b", "--"}, Streams{Stdout: &stdout, Stderr: &stderr}) + 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) } @@ -56,7 +56,7 @@ func TestRuntimeRunAndBuild(t *testing.T) { if err := os.WriteFile(final, []byte("old"), 0755); err != nil { t.Fatal(err) } - status, gotFinal, err := resolver.Build(context.Background(), rt, final, Streams{Stdout: &stdout, Stderr: &stderr}) + 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) } @@ -66,13 +66,13 @@ func TestRuntimeRunAndBuild(t *testing.T) { } } -func TestRuntimeBuildFailurePreservesOutput(t *testing.T) { +func TestDriverBuildFailurePreservesOutput(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } - fixture := newRuntimeFixture(t) + fixture := newDriverFixture(t) resolver := fixture.resolver(t) - rt := resolveRuntime(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) + drv := resolveDriver(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) final := filepath.Join(fixture.root, "game") if runtime.GOOS == "windows" { final += ".exe" @@ -80,8 +80,8 @@ func TestRuntimeBuildFailurePreservesOutput(t *testing.T) { if err := os.WriteFile(final, []byte("old"), 0755); err != nil { t.Fatal(err) } - t.Setenv("FAKE_PROVIDER_EXIT", "42") - status, _, err := resolver.Build(context.Background(), rt, final, Streams{Stdout: new(bytes.Buffer), Stderr: new(bytes.Buffer)}) + 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) } @@ -92,13 +92,13 @@ func TestRuntimeBuildFailurePreservesOutput(t *testing.T) { assertNoOutputWorkDirs(t, filepath.Dir(final)) } -func TestRuntimeBuildCancellationPreservesOutput(t *testing.T) { +func TestDriverBuildCancellationPreservesOutput(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } - fixture := newRuntimeFixture(t) + fixture := newDriverFixture(t) resolver := fixture.resolver(t) - rt := resolveRuntime(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) + drv := resolveDriver(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) final := filepath.Join(fixture.root, "game") if runtime.GOOS == "windows" { final += ".exe" @@ -106,14 +106,14 @@ func TestRuntimeBuildCancellationPreservesOutput(t *testing.T) { if err := os.WriteFile(final, []byte("old"), 0755); err != nil { t.Fatal(err) } - marker := filepath.Join(fixture.root, "provider-started") - t.Setenv("FAKE_PROVIDER_MARKER", marker) - t.Setenv("FAKE_PROVIDER_BLOCK", "1") + 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, rt, final, Streams{Stdout: new(bytes.Buffer), Stderr: new(bytes.Buffer)}) + _, _, _ = resolver.Build(ctx, drv, final, Streams{Stdout: new(bytes.Buffer), Stderr: new(bytes.Buffer)}) }() deadline := time.Now().Add(15 * time.Second) for { @@ -123,7 +123,7 @@ func TestRuntimeBuildCancellationPreservesOutput(t *testing.T) { if time.Now().After(deadline) { cancel() <-done - t.Fatal("runtime provider did not start") + t.Fatal("driver did not start") } time.Sleep(10 * time.Millisecond) } @@ -131,7 +131,7 @@ func TestRuntimeBuildCancellationPreservesOutput(t *testing.T) { select { case <-done: case <-time.After(15 * time.Second): - t.Fatal("canceled runtime provider did not exit") + t.Fatal("canceled driver did not exit") } data, readErr := os.ReadFile(final) if readErr != nil || string(data) != "old" { @@ -142,25 +142,25 @@ func TestRuntimeBuildCancellationPreservesOutput(t *testing.T) { func assertNoOutputWorkDirs(t *testing.T, parent string) { t.Helper() - matches, err := filepath.Glob(filepath.Join(parent, ".xgo-runtime-output-*")) + matches, err := filepath.Glob(filepath.Join(parent, ".xgo-driver-output-*")) if err != nil { t.Fatal(err) } if len(matches) != 0 { - t.Fatalf("runtime output work directories remain: %v", matches) + t.Fatalf("driver output work directories remain: %v", matches) } } -func TestRuntimeInstallUsesEffectiveGOBIN(t *testing.T) { +func TestDriverInstallUsesEffectiveGOBIN(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } - fixture := newRuntimeFixture(t) + fixture := newDriverFixture(t) resolver := fixture.resolver(t) - rt := resolveRuntime(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) + 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(), rt, Streams{Stdout: new(bytes.Buffer), Stderr: new(bytes.Buffer)}) + 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) } @@ -173,16 +173,16 @@ func TestRuntimeInstallUsesEffectiveGOBIN(t *testing.T) { } } -func TestRuntimeInstallValidatesPolicyBeforeCreatingGOBIN(t *testing.T) { +func TestDriverInstallValidatesPolicyBeforeCreatingGOBIN(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } - fixture := newRuntimeFixture(t) + fixture := newDriverFixture(t) resolver := fixture.resolver(t, "-tags=unsupported") - rt := resolveRuntime(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) + 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(), rt, Streams{Stdout: new(bytes.Buffer), Stderr: new(bytes.Buffer)}) + 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) } diff --git a/cmd/internal/runtimeprovider/executable_darwin.go b/cmd/internal/projectdriver/executable_darwin.go similarity index 79% rename from cmd/internal/runtimeprovider/executable_darwin.go rename to cmd/internal/projectdriver/executable_darwin.go index 1a8b151df..492d420e1 100644 --- a/cmd/internal/runtimeprovider/executable_darwin.go +++ b/cmd/internal/projectdriver/executable_darwin.go @@ -16,7 +16,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "bytes" @@ -33,28 +33,28 @@ import ( func validateHostExecutable(input *os.File, path string) error { file, err := macho.NewFile(input) if err != nil { - return fmt.Errorf("runtime provider output is not a Darwin executable: %w", err) + 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 runtime provider output %q: %w", path, err) + 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("runtime provider output changed before signature validation") + 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("runtime provider output has no valid Darwin signature: %w: %s", err, strings.TrimSpace(string(output))) + 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("runtime provider output changed during signature validation") + return fmt.Errorf("driver output changed during signature validation") } return nil } diff --git a/cmd/internal/runtimeprovider/executable_linux.go b/cmd/internal/projectdriver/executable_linux.go similarity index 88% rename from cmd/internal/runtimeprovider/executable_linux.go rename to cmd/internal/projectdriver/executable_linux.go index 4b3cdddbc..ffaf20c35 100644 --- a/cmd/internal/runtimeprovider/executable_linux.go +++ b/cmd/internal/projectdriver/executable_linux.go @@ -16,7 +16,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "debug/elf" @@ -27,7 +27,7 @@ import ( func validateHostExecutable(input *os.File, _ string) error { file, err := elf.NewFile(input) if err != nil { - return fmt.Errorf("runtime provider output is not a Linux executable: %w", err) + return fmt.Errorf("driver output is not a Linux executable: %w", err) } return file.Close() } diff --git a/cmd/internal/runtimeprovider/executable_other.go b/cmd/internal/projectdriver/executable_other.go similarity index 96% rename from cmd/internal/runtimeprovider/executable_other.go rename to cmd/internal/projectdriver/executable_other.go index c8d1e0e75..bdc7305ef 100644 --- a/cmd/internal/runtimeprovider/executable_other.go +++ b/cmd/internal/projectdriver/executable_other.go @@ -16,7 +16,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import "os" diff --git a/cmd/internal/runtimeprovider/executable_windows.go b/cmd/internal/projectdriver/executable_windows.go similarity index 88% rename from cmd/internal/runtimeprovider/executable_windows.go rename to cmd/internal/projectdriver/executable_windows.go index 2a8ebb380..2b5d34451 100644 --- a/cmd/internal/runtimeprovider/executable_windows.go +++ b/cmd/internal/projectdriver/executable_windows.go @@ -16,7 +16,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "debug/pe" @@ -27,7 +27,7 @@ import ( func validateHostExecutable(input *os.File, _ string) error { file, err := pe.NewFile(input) if err != nil { - return fmt.Errorf("runtime provider output is not a Windows executable: %w", err) + return fmt.Errorf("driver output is not a Windows executable: %w", err) } return file.Close() } diff --git a/cmd/internal/runtimeprovider/flags.go b/cmd/internal/projectdriver/flags.go similarity index 91% rename from cmd/internal/runtimeprovider/flags.go rename to cmd/internal/projectdriver/flags.go index d5d4bc0c2..750e2936d 100644 --- a/cmd/internal/runtimeprovider/flags.go +++ b/cmd/internal/projectdriver/flags.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "fmt" @@ -78,8 +78,8 @@ func (p GraphPolicy) goArgs(command string, args ...string) []string { return append(ret, args...) } -// parseRuntimeFlags extracts discovery policy and defers rejected flags. -func parseRuntimeFlags(projectDir, goCommand, goWork, ambient string, cli []string) (parsedFlags, error) { +// 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) @@ -100,8 +100,8 @@ func parseRuntimeFlags(projectDir, goCommand, goWork, ambient string, cli []stri switch mode { case modModeMod, modModeReadonly, modModeVendor: default: - // Keep malformed runtime-only policy deferred until a - // runtime project is selected. Legacy Go/XGo commands are + // 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 @@ -121,7 +121,7 @@ func parseRuntimeFlags(projectDir, goCommand, goWork, ambient string, cli []stri ret.graph.ModFile = path } else { ret.graph.Overlay = path - // Discover through the overlay; reject it for matched runtimes. + // Discover through the overlay; reject it for matched drivers. ret.rejected = append(ret.rejected, arg) } case "v": @@ -162,11 +162,11 @@ func parseRuntimeFlags(projectDir, goCommand, goWork, ambient string, cli []stri return ret, nil } -func (p parsedFlags) validateRuntime() error { +func (p parsedFlags) validateDriver() error { if len(p.rejected) == 0 { return nil } - return fmt.Errorf("runtime provider v1 does not support flag %s", p.rejected[0]) + return fmt.Errorf("driver v1 does not support flag %s", p.rejected[0]) } func splitCanonicalFlag(arg string) (name, value string, ok bool) { diff --git a/cmd/internal/runtimeprovider/flags_test.go b/cmd/internal/projectdriver/flags_test.go similarity index 77% rename from cmd/internal/runtimeprovider/flags_test.go rename to cmd/internal/projectdriver/flags_test.go index 073dbf84a..ebab7b6a4 100644 --- a/cmd/internal/runtimeprovider/flags_test.go +++ b/cmd/internal/projectdriver/flags_test.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "path/filepath" @@ -73,9 +73,9 @@ func TestSplitQuotedFields(t *testing.T) { } } -func TestParseRuntimeFlags(t *testing.T) { +func TestParseDriverFlags(t *testing.T) { dir := t.TempDir() - got, err := parseRuntimeFlags(dir, "/usr/bin/go", "off", + 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 { @@ -94,14 +94,14 @@ func TestParseRuntimeFlags(t *testing.T) { if !got.build.DisableBuildVCS || !got.build.TrimPath { t.Fatalf("build flags = %#v", got.build) } - if err := got.validateRuntime(); err != nil { + if err := got.validateDriver(); err != nil { t.Fatal(err) } } -func TestParseRuntimeFlagsDefersOverlayRejection(t *testing.T) { +func TestParseDriverFlagsDefersOverlayRejection(t *testing.T) { dir := t.TempDir() - got, err := parseRuntimeFlags(dir, "/usr/bin/go", "off", `'-overlay=old overlay.json'`, nil) + got, err := parseDriverFlags(dir, "/usr/bin/go", "off", `'-overlay=old overlay.json'`, nil) if err != nil { t.Fatal(err) } @@ -109,14 +109,14 @@ func TestParseRuntimeFlagsDefersOverlayRejection(t *testing.T) { if !reflect.DeepEqual(got.graph.goFlags(), wantGraph) { t.Fatalf("graph flags = %#v, want %#v", got.graph.goFlags(), wantGraph) } - if err := got.validateRuntime(); err == nil || !strings.Contains(err.Error(), "overlay") { - t.Fatalf("validateRuntime() = %v, want deferred overlay rejection", err) + if err := got.validateDriver(); err == nil || !strings.Contains(err.Error(), "overlay") { + t.Fatalf("validateDriver() = %v, want deferred overlay rejection", err) } } -func TestParseRuntimeFlagsAcceptsDoubleDashForms(t *testing.T) { +func TestParseDriverFlagsAcceptsDoubleDashForms(t *testing.T) { dir := t.TempDir() - got, err := parseRuntimeFlags(dir, "/usr/bin/go", "off", `--mod=readonly --trimpath`, []string{"--mod=mod", "--buildvcs=false"}) + got, err := parseDriverFlags(dir, "/usr/bin/go", "off", `--mod=readonly --trimpath`, []string{"--mod=mod", "--buildvcs=false"}) if err != nil { t.Fatal(err) } @@ -126,15 +126,15 @@ func TestParseRuntimeFlagsAcceptsDoubleDashForms(t *testing.T) { if !got.build.TrimPath || !got.build.DisableBuildVCS { t.Fatalf("build flags = %#v", got.build) } - if err := got.validateRuntime(); err != nil { + if err := got.validateDriver(); err != nil { t.Fatal(err) } for _, flag := range []string{"--", "---trimpath"} { - got, err := parseRuntimeFlags(dir, "/usr/bin/go", "off", "", []string{flag}) + got, err := parseDriverFlags(dir, "/usr/bin/go", "off", "", []string{flag}) if err != nil { t.Fatal(err) } - if err := got.validateRuntime(); err == nil { + if err := got.validateDriver(); err == nil { t.Fatalf("invalid flag %q was accepted", flag) } } @@ -157,14 +157,14 @@ func TestBuildPolicyFlagForms(t *testing.T) { } } -func TestParseRuntimeFlagsRejected(t *testing.T) { +func TestParseDriverFlagsRejected(t *testing.T) { for _, flag := range []string{"-n=true", "-tags=foo", "-buildmode=pie", "-buildvcs=true", "-trimpath=false"} { - got, err := parseRuntimeFlags(t.TempDir(), "/usr/bin/go", "off", "", []string{flag}) + got, err := parseDriverFlags(t.TempDir(), "/usr/bin/go", "off", "", []string{flag}) if err != nil { - t.Fatalf("parseRuntimeFlags(%q): %v", flag, err) + t.Fatalf("parseDriverFlags(%q): %v", flag, err) } - if err := got.validateRuntime(); err == nil || !strings.Contains(err.Error(), strings.Split(strings.TrimPrefix(flag, "-"), "=")[0]) { - t.Fatalf("validateRuntime(%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/runtimeprovider/graph.go b/cmd/internal/projectdriver/graph.go similarity index 98% rename from cmd/internal/runtimeprovider/graph.go rename to cmd/internal/projectdriver/graph.go index f79c63df4..d6c90eea0 100644 --- a/cmd/internal/runtimeprovider/graph.go +++ b/cmd/internal/projectdriver/graph.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "bytes" @@ -29,7 +29,7 @@ import ( gomodfile "golang.org/x/mod/modfile" ) -var errNoGoModule = errors.New("runtime discovery requires a Go module") +var errNoGoModule = errors.New("driver discovery requires a Go module") type fileIdentity = modload.FileIdentity @@ -54,7 +54,7 @@ type goListModule struct { } // sameResolvedModule compares the complete selected module provenance used at -// provider and package trust boundaries. +// driver and package trust boundaries. func sameResolvedModule(a, b ResolvedModule) bool { if a.Main != b.Main || a.Selected != b.Selected { return false diff --git a/cmd/internal/runtimeprovider/graph_command.go b/cmd/internal/projectdriver/graph_command.go similarity index 96% rename from cmd/internal/runtimeprovider/graph_command.go rename to cmd/internal/projectdriver/graph_command.go index e5e30e227..412dec277 100644 --- a/cmd/internal/runtimeprovider/graph_command.go +++ b/cmd/internal/projectdriver/graph_command.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "bytes" @@ -52,7 +52,7 @@ func preparePolicies(ctx context.Context, cwd string, cli []string) (parsedFlags if err != nil { return parsedFlags{}, err } - policy, err := parseRuntimeFlags(cwd, goCommand, "off", ambient, cli) + policy, err := parseDriverFlags(cwd, goCommand, "off", ambient, cli) if err != nil { return parsedFlags{}, err } @@ -80,7 +80,7 @@ func preparePolicies(ctx context.Context, cwd string, cli []string) (parsedFlags return policy, nil } -// sanitizeGraphFlags defers missing graph files as runtime-only policy errors. +// 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) { diff --git a/cmd/internal/runtimeprovider/graph_overlay.go b/cmd/internal/projectdriver/graph_overlay.go similarity index 98% rename from cmd/internal/runtimeprovider/graph_overlay.go rename to cmd/internal/projectdriver/graph_overlay.go index 1e0cca324..d66051ead 100644 --- a/cmd/internal/runtimeprovider/graph_overlay.go +++ b/cmd/internal/projectdriver/graph_overlay.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "encoding/json" @@ -349,7 +349,7 @@ func (v *graphFileView) canonicalFile(path string) (string, error) { } // canonicalDir is the directory counterpart to canonicalFile. -// Synthetic directories are safe because overlay runtime targets never execute. +// Synthetic directories are safe because overlay-backed drivers never execute. func (v *graphFileView) canonicalDir(path string) (string, error) { if !v.hasOverlay() { return canonicalExistingDir(path) diff --git a/cmd/internal/runtimeprovider/graph_package.go b/cmd/internal/projectdriver/graph_package.go similarity index 98% rename from cmd/internal/runtimeprovider/graph_package.go rename to cmd/internal/projectdriver/graph_package.go index 8eb207ec9..02e869592 100644 --- a/cmd/internal/runtimeprovider/graph_package.go +++ b/cmd/internal/projectdriver/graph_package.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "bytes" @@ -118,7 +118,7 @@ func moduleOwnsPackage(ctx context.Context, policy GraphPolicy, moduleGoMod, dir func listPackageTarget(ctx context.Context, importPath, workDir string, policy GraphPolicy) (goListPackage, error) { if importPath == "" || strings.Contains(importPath, "@") || strings.Contains(importPath, "...") { - return goListPackage{}, fmt.Errorf("runtime provider does not support package target %q", 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 { diff --git a/cmd/internal/runtimeprovider/graph_paths.go b/cmd/internal/projectdriver/graph_paths.go similarity index 98% rename from cmd/internal/runtimeprovider/graph_paths.go rename to cmd/internal/projectdriver/graph_paths.go index 90847eefd..d794d1514 100644 --- a/cmd/internal/runtimeprovider/graph_paths.go +++ b/cmd/internal/projectdriver/graph_paths.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "fmt" diff --git a/cmd/internal/runtimeprovider/graph_test.go b/cmd/internal/projectdriver/graph_test.go similarity index 96% rename from cmd/internal/runtimeprovider/graph_test.go rename to cmd/internal/projectdriver/graph_test.go index 3b1dcb0a9..ebfd169c0 100644 --- a/cmd/internal/runtimeprovider/graph_test.go +++ b/cmd/internal/projectdriver/graph_test.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "context" @@ -191,7 +191,7 @@ func TestLoadEffectiveGraphLocalReplace(t *testing.T) { app := filepath.Join(root, "app") framework := filepath.Join(root, "framework") mustMkdirAll(t, filepath.Join(app, "game")) - mustMkdirAll(t, filepath.Join(framework, "cmd", "provider")) + mustMkdirAll(t, filepath.Join(framework, "cmd", "driver")) mustWriteFile(t, filepath.Join(app, "go.mod"), `module example.test/app go 1.25 @@ -201,7 +201,7 @@ 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", "provider", "main.go"), "package main\nfunc main() {}\n") + 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) @@ -222,11 +222,11 @@ replace example.test/framework => ../framework 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/provider", app, policy.graph) + 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", "provider") || module.Selected.Path != "example.test/framework" { + if dir != filepath.Join(framework, "cmd", "driver") || module.Selected.Path != "example.test/framework" { t.Fatalf("resolved package = %q, %#v", dir, module) } } @@ -271,10 +271,10 @@ func TestLoadEffectiveGraphModfile(t *testing.T) { t.Setenv("GOWORK", "off") dir := t.TempDir() mustModuleFile(t, filepath.Join(dir, "go.mod"), "example.test/app") - mustModuleFile(t, filepath.Join(dir, "runtime.mod"), "example.test/app") - policy := mustPolicies(t, dir, "-modfile=runtime.mod") + 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, "runtime.mod")) + wantModfile, err := canonicalExistingFile(filepath.Join(dir, "driver.mod")) if err != nil { t.Fatal(err) } diff --git a/cmd/internal/runtimeprovider/output.go b/cmd/internal/projectdriver/output.go similarity index 72% rename from cmd/internal/runtimeprovider/output.go rename to cmd/internal/projectdriver/output.go index ce9b23e3b..2c9738e46 100644 --- a/cmd/internal/runtimeprovider/output.go +++ b/cmd/internal/projectdriver/output.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "context" @@ -45,7 +45,7 @@ type outputTransaction struct { func resolveBuildOutput(cwd, requested, defaultName string) (string, error) { if defaultName == "" { - return "", fmt.Errorf("empty runtime executable name") + return "", fmt.Errorf("empty driver executable name") } defaultName = executableName(defaultName) path := requested @@ -95,7 +95,7 @@ func beginOutputTransaction(final string, keepWork bool) (*outputTransaction, er if err != nil { _ = root.RemoveAll(workName) _ = root.Close() - return nil, fmt.Errorf("inspect runtime output work directory: %w", err) + return nil, fmt.Errorf("inspect driver output work directory: %w", err) } stagedName := filepath.Join(workName, finalName) return &outputTransaction{ @@ -115,28 +115,28 @@ func beginOutputTransaction(final string, keepWork bool) (*outputTransaction, er func openPinnedOutputParent(parent string) (*os.Root, error) { before, err := os.Stat(parent) if err != nil { - return nil, fmt.Errorf("runtime output parent: %w", err) + return nil, fmt.Errorf("driver output parent: %w", err) } if !before.IsDir() { - return nil, fmt.Errorf("runtime output parent %q is not a directory", parent) + 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 runtime output parent: %w", err) + 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 runtime output parent: %w", err) + 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 runtime output parent: %w", err) + 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("runtime output parent %q changed while it was opened", parent) + return nil, fmt.Errorf("driver output parent %q changed while it was opened", parent) } return root, nil } @@ -145,16 +145,16 @@ 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 runtime output work directory: %w", err) + return "", fmt.Errorf("generate driver output work directory: %w", err) } - name := ".xgo-runtime-output-" + hex.EncodeToString(random[:]) + 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 runtime output work directory: %w", err) + return "", fmt.Errorf("create driver output work directory: %w", err) } } - return "", fmt.Errorf("create runtime output work directory: too many name collisions") + return "", fmt.Errorf("create driver output work directory: too many name collisions") } func validateExistingFinal(root *os.Root, name, displayPath string) error { @@ -163,13 +163,13 @@ func validateExistingFinal(root *os.Root, name, displayPath string) error { return nil } if err != nil { - return fmt.Errorf("inspect runtime output %q: %w", displayPath, err) + return fmt.Errorf("inspect driver output %q: %w", displayPath, err) } if info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("runtime output %q is a symlink", displayPath) + return fmt.Errorf("driver output %q is a symlink", displayPath) } if !info.Mode().IsRegular() { - return fmt.Errorf("runtime output %q is not a regular file", displayPath) + return fmt.Errorf("driver output %q is not a regular file", displayPath) } return nil } @@ -189,7 +189,7 @@ func (tx *outputTransaction) abort() { // Cancellation is checked immediately before the commit rename. func (tx *outputTransaction) commitContext(ctx context.Context) error { if tx == nil || tx.closed { - return fmt.Errorf("runtime output transaction is closed") + return fmt.Errorf("driver output transaction is closed") } if ctx == nil { ctx = context.Background() @@ -202,68 +202,68 @@ func (tx *outputTransaction) commitContext(ctx context.Context) error { } workInfo, err := tx.parent.Lstat(tx.workName) if err != nil { - return fmt.Errorf("inspect runtime output work directory: %w", err) + 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("runtime output work directory changed during provider execution") + 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("runtime provider must create exactly one staged output") + return fmt.Errorf("driver must create exactly one staged output") } info, err := tx.parent.Lstat(tx.stagedName) if err != nil { - return fmt.Errorf("runtime provider output: %w", err) + return fmt.Errorf("driver output: %w", err) } if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return fmt.Errorf("runtime provider output %q is not a regular non-symlink file", tx.staged) + return fmt.Errorf("driver output %q is not a regular non-symlink file", tx.staged) } if info.Size() == 0 { - return fmt.Errorf("runtime provider output %q is empty", tx.staged) + return fmt.Errorf("driver output %q is empty", tx.staged) } if runtime.GOOS != "windows" && info.Mode().Perm()&0111 == 0 { - return fmt.Errorf("runtime provider output %q is not executable", tx.staged) + 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 runtime provider output: %w", err) + return fmt.Errorf("open driver output: %w", err) } openedInfo, err := file.Stat() if err != nil { _ = file.Close() - return fmt.Errorf("inspect open runtime provider output: %w", err) + return fmt.Errorf("inspect open driver output: %w", err) } if !os.SameFile(info, openedInfo) || !openedInfo.Mode().IsRegular() { _ = file.Close() - return fmt.Errorf("runtime provider output changed while it was opened") + return fmt.Errorf("driver output changed while it was opened") } if err := file.Sync(); err != nil { _ = file.Close() - return fmt.Errorf("sync runtime provider output: %w", err) + 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 runtime provider output: %w", err) + 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("runtime provider output changed after validation") + 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("runtime provider output aliases existing final output") + return fmt.Errorf("driver output aliases existing final output") } else if err != nil && !errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("reinspect runtime output %q: %w", tx.final, err) + return fmt.Errorf("reinspect driver output %q: %w", tx.final, err) } - // Recheck the user-visible parent after provider execution. + // Recheck the user-visible parent after driver execution. if err := tx.checkParentPath(); err != nil { return err } @@ -277,7 +277,7 @@ func (tx *outputTransaction) commitContext(ctx context.Context) error { } else if !errors.Is(statErr, fs.ErrNotExist) { state = statErr.Error() } - return fmt.Errorf("commit runtime output (final state %s): %w", state, err) + return fmt.Errorf("commit driver output (final state %s): %w", state, err) } // Rename is the commit point; later checks are diagnostic only. _ = tx.checkParentPath() @@ -294,14 +294,14 @@ func (tx *outputTransaction) commitContext(ctx context.Context) error { func (tx *outputTransaction) checkParentPath() error { pinned, err := tx.parent.Stat(".") if err != nil { - return fmt.Errorf("inspect pinned runtime output parent: %w", err) + return fmt.Errorf("inspect pinned driver output parent: %w", err) } current, err := os.Stat(tx.parentPath) if err != nil { - return fmt.Errorf("revalidate runtime output parent %q: %w", tx.parentPath, err) + return fmt.Errorf("revalidate driver output parent %q: %w", tx.parentPath, err) } if !current.IsDir() || !os.SameFile(pinned, current) { - return fmt.Errorf("runtime output parent %q changed during provider execution", tx.parentPath) + return fmt.Errorf("driver output parent %q changed during driver execution", tx.parentPath) } return nil } diff --git a/cmd/internal/runtimeprovider/output_test.go b/cmd/internal/projectdriver/output_test.go similarity index 99% rename from cmd/internal/runtimeprovider/output_test.go rename to cmd/internal/projectdriver/output_test.go index 5b351ea46..11c6418d7 100644 --- a/cmd/internal/runtimeprovider/output_test.go +++ b/cmd/internal/projectdriver/output_test.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "context" diff --git a/cmd/internal/runtimeprovider/process_unix.go b/cmd/internal/projectdriver/process_unix.go similarity index 55% rename from cmd/internal/runtimeprovider/process_unix.go rename to cmd/internal/projectdriver/process_unix.go index 4e1d28456..9ec506f9a 100644 --- a/cmd/internal/runtimeprovider/process_unix.go +++ b/cmd/internal/projectdriver/process_unix.go @@ -16,7 +16,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "context" @@ -29,15 +29,15 @@ import ( ) const ( - providerProcessGracePeriod = 2 * time.Second - providerProcessKillWait = 2 * time.Second + driverProcessGracePeriod = 2 * time.Second + driverProcessKillWait = 2 * time.Second ) -func runProviderProcess(ctx context.Context, cmd *exec.Cmd) (ProcessStatus, error) { +func runDriverProcess(ctx context.Context, cmd *exec.Cmd) (ProcessStatus, error) { if err := ctx.Err(); err != nil { return ProcessStatus{}, err } - configureProviderProcessGroup(cmd) + configureDriverProcessGroup(cmd) if err := cmd.Start(); err != nil { return ProcessStatus{}, err } @@ -49,22 +49,22 @@ func runProviderProcess(ctx context.Context, cmd *exec.Cmd) (ProcessStatus, erro select { case err := <-wait: - if _, cleanupErr := stopProviderProcessGroup(pgid, syscall.SIGTERM, nil); cleanupErr != nil { + if _, cleanupErr := stopDriverProcessGroup(pgid, syscall.SIGTERM, nil); cleanupErr != nil { return ProcessStatus{}, cleanupErr } - return providerExitStatus(ctx, err) + return driverExitStatus(ctx, err) case <-ctx.Done(): } - initial := providerCancellationSignal(ctx) - err, cleanupErr := stopProviderProcessGroup(pgid, initial, wait) + initial := driverCancellationSignal(ctx) + err, cleanupErr := stopDriverProcessGroup(pgid, initial, wait) if cleanupErr != nil { return ProcessStatus{}, cleanupErr } - return providerExitStatus(ctx, err) + return driverExitStatus(ctx, err) } -func configureProviderProcessGroup(cmd *exec.Cmd) { +func configureDriverProcessGroup(cmd *exec.Cmd) { if cmd.SysProcAttr == nil { cmd.SysProcAttr = &syscall.SysProcAttr{} } else { @@ -75,58 +75,58 @@ func configureProviderProcessGroup(cmd *exec.Cmd) { cmd.SysProcAttr.Pgid = 0 } -func providerCancellationSignal(ctx context.Context) syscall.Signal { - var cause runtimeSignalCause +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 } -// stopProviderProcessGroup allows the initial signal a bounded grace period, +// 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 stopProviderProcessGroup(pgid int, initial syscall.Signal, wait <-chan error) (error, error) { - state := providerProcessWait{wait: wait, leaderDone: wait == nil} - groupDone := !providerProcessGroupExists(pgid) +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 := signalProviderProcessGroup(pgid, initial); err != nil && !errors.Is(err, syscall.ESRCH) { - signalErr = fmt.Errorf("signal runtime provider process group: %w", err) + 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, providerProcessGracePeriod) + groupDone = state.waitForProcessGroup(pgid, driverProcessGracePeriod) if state.leaderDone && groupDone { return state.leaderErr, signalErr } - if err := signalProviderProcessGroup(pgid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { - signalErr = errors.Join(signalErr, fmt.Errorf("kill runtime provider process group: %w", err)) + 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, providerProcessKillWait) + groupDone = state.waitForProcessGroup(pgid, driverProcessKillWait) if !state.leaderDone { - return state.leaderErr, errors.Join(signalErr, fmt.Errorf("runtime provider did not exit after SIGKILL")) + return state.leaderErr, errors.Join(signalErr, fmt.Errorf("driver did not exit after SIGKILL")) } if !groupDone { - return state.leaderErr, errors.Join(signalErr, fmt.Errorf("runtime provider descendants did not exit after SIGKILL")) + return state.leaderErr, errors.Join(signalErr, fmt.Errorf("driver descendants did not exit after SIGKILL")) } return state.leaderErr, signalErr } -type providerProcessWait struct { +type driverProcessWait struct { wait <-chan error leaderDone bool leaderErr error } -func (s *providerProcessWait) waitForProcessGroup(pgid int, timeout time.Duration) bool { +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 := !providerProcessGroupExists(pgid) + groupDone := !driverProcessGroupExists(pgid) if s.leaderDone && groupDone { return true } @@ -137,16 +137,16 @@ func (s *providerProcessWait) waitForProcessGroup(pgid int, timeout time.Duratio s.wait = nil case <-poll.C: case <-timer.C: - return !providerProcessGroupExists(pgid) + return !driverProcessGroupExists(pgid) } } } -func signalProviderProcessGroup(pgid int, sig syscall.Signal) error { +func signalDriverProcessGroup(pgid int, sig syscall.Signal) error { return syscall.Kill(-pgid, sig) } -func providerProcessGroupExists(pgid int) bool { +func driverProcessGroupExists(pgid int) bool { err := syscall.Kill(-pgid, 0) return err == nil || errors.Is(err, syscall.EPERM) } diff --git a/cmd/internal/runtimeprovider/process_unix_test.go b/cmd/internal/projectdriver/process_unix_test.go similarity index 63% rename from cmd/internal/runtimeprovider/process_unix_test.go rename to cmd/internal/projectdriver/process_unix_test.go index b7026d56f..13b28f82a 100644 --- a/cmd/internal/runtimeprovider/process_unix_test.go +++ b/cmd/internal/projectdriver/process_unix_test.go @@ -16,7 +16,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "context" @@ -32,63 +32,63 @@ import ( "time" ) -const runtimeProviderHelperEnv = "XGO_RUNTIME_PROVIDER_PROCESS_HELPER" +const driverHelperEnv = "XGO_DRIVER_PROCESS_HELPER" -type providerProcessResult struct { +type driverProcessResult struct { status ProcessStatus err error } -func TestRunProviderProcessForwardsRuntimeSignal(t *testing.T) { +func TestRunDriverProcessForwardsDriverSignal(t *testing.T) { dir := t.TempDir() ready := filepath.Join(dir, "ready") received := filepath.Join(dir, "received") - cmd := runtimeProviderHelperCommand("handle", ready, received, strconv.Itoa(int(syscall.SIGHUP))) - boundary := beginRuntimeSignalBoundary(context.Background()) - result := make(chan providerProcessResult, 1) + cmd := driverHelperCommand("handle", ready, received, strconv.Itoa(int(syscall.SIGHUP))) + boundary := beginDriverSignalBoundary(context.Background()) + result := make(chan driverProcessResult, 1) go func() { - status, err := runProviderProcess(boundary.Context(), cmd) - result <- providerProcessResult{status: status, err: err} + 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 := waitForProviderProcessResult(t, result) - var cause runtimeSignalCause + got := waitForDriverProcessResult(t, result) + var cause driverSignalCause if !errors.As(got.err, &cause) || cause.signal != syscall.SIGHUP { - t.Fatalf("runProviderProcess() = (%+v, %v), want SIGHUP cancellation cause", got.status, got.err) + 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(runProviderProcess()) = (%+v, %v), want SIGHUP status", status, err) + t.Fatalf("Finish(runDriverProcess()) = (%+v, %v), want SIGHUP status", status, err) } if signal := waitForHelperSignal(t, received); signal != syscall.SIGHUP { - t.Fatalf("provider received %v, want SIGHUP", signal) + t.Fatalf("driver received %v, want SIGHUP", signal) } } -func TestRunProviderProcessReturnsCancellationAfterGracefulExit(t *testing.T) { +func TestRunDriverProcessReturnsCancellationAfterGracefulExit(t *testing.T) { dir := t.TempDir() ready := filepath.Join(dir, "ready") received := filepath.Join(dir, "received") - cmd := runtimeProviderHelperCommand("handle", ready, received, strconv.Itoa(int(syscall.SIGTERM))) + cmd := driverHelperCommand("handle", ready, received, strconv.Itoa(int(syscall.SIGTERM))) ctx, cancel := context.WithCancel(context.Background()) - result := make(chan providerProcessResult, 1) + result := make(chan driverProcessResult, 1) go func() { - status, err := runProviderProcess(ctx, cmd) - result <- providerProcessResult{status: status, err: err} + 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 := waitForProviderProcessResult(t, result) + got := waitForDriverProcessResult(t, result) if !errors.Is(got.err, context.Canceled) { - t.Fatalf("runProviderProcess() = (%+v, %v), want context cancellation", got.status, got.err) + t.Fatalf("runDriverProcess() = (%+v, %v), want context cancellation", got.status, got.err) } if signal := waitForHelperSignal(t, received); signal != syscall.SIGTERM { - t.Fatalf("provider received %v, want SIGTERM", signal) + t.Fatalf("driver received %v, want SIGTERM", signal) } } @@ -101,69 +101,69 @@ func TestStatusUnlessCanceledRejectsSuccessfulExitAfterCancellation(t *testing.T } } -func TestRunProviderProcessEscalatesIgnoredCancellation(t *testing.T) { +func TestRunDriverProcessEscalatesIgnoredCancellation(t *testing.T) { dir := t.TempDir() ready := filepath.Join(dir, "ready") received := filepath.Join(dir, "received") - cmd := runtimeProviderHelperCommand("resist", ready, received, strconv.Itoa(int(syscall.SIGTERM))) + cmd := driverHelperCommand("resist", ready, received, strconv.Itoa(int(syscall.SIGTERM))) ctx, cancel := context.WithCancel(context.Background()) - result := make(chan providerProcessResult, 1) + result := make(chan driverProcessResult, 1) go func() { - status, err := runProviderProcess(ctx, cmd) - result <- providerProcessResult{status: status, err: err} + 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 := waitForProviderProcessResult(t, result) + got := waitForDriverProcessResult(t, result) if got.err != nil || !got.status.Signaled || got.status.Signal != syscall.SIGKILL { - t.Fatalf("runProviderProcess() = (%+v, %v), want SIGKILL after grace period", got.status, got.err) + t.Fatalf("runDriverProcess() = (%+v, %v), want SIGKILL after grace period", got.status, got.err) } if signal := waitForHelperSignal(t, received); signal != syscall.SIGTERM { - t.Fatalf("provider received %v before escalation, want SIGTERM", signal) + t.Fatalf("driver received %v before escalation, want SIGTERM", signal) } } -func TestRunProviderProcessCleansDescendantsAfterLeaderExit(t *testing.T) { +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 := runtimeProviderHelperCommand("spawn-descendant", childPID, childReady, childSignal) + cmd := driverHelperCommand("spawn-descendant", childPID, childReady, childSignal) - status, err := runProviderProcess(context.Background(), cmd) + 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("runProviderProcess() = (%+v, %v), want successful leader status", status, err) + 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 provider return: %v", pid, err) + t.Fatalf("same-group descendant %d still exists after driver return: %v", pid, err) } } -func TestRuntimeProviderProcessHelper(t *testing.T) { - if os.Getenv(runtimeProviderHelperEnv) != "1" { +func TestDriverProcessHelper(t *testing.T) { + if os.Getenv(driverHelperEnv) != "1" { return } - args := runtimeProviderHelperArgs() + args := driverHelperArgs() if len(args) == 0 { t.Fatal("missing helper mode") } switch args[0] { case "handle": - runRuntimeProviderSignalHelper(t, args[1:], true) + runDriverSignalHelper(t, args[1:], true) case "resist": - runRuntimeProviderSignalHelper(t, args[1:], false) + runDriverSignalHelper(t, args[1:], false) case "spawn-descendant": if len(args) != 4 { t.Fatalf("spawn-descendant args = %q", args) } - child := runtimeProviderHelperCommand("resist", args[2], args[3], strconv.Itoa(int(syscall.SIGTERM))) + child := driverHelperCommand("resist", args[2], args[3], strconv.Itoa(int(syscall.SIGTERM))) if err := child.Start(); err != nil { t.Fatal(err) } @@ -180,7 +180,7 @@ func TestRuntimeProviderProcessHelper(t *testing.T) { } } -func runRuntimeProviderSignalHelper(t *testing.T, args []string, exitAfterSignal bool) { +func runDriverSignalHelper(t *testing.T, args []string, exitAfterSignal bool) { if len(args) != 3 { t.Fatalf("signal helper args = %q", args) } @@ -210,15 +210,15 @@ func runRuntimeProviderSignalHelper(t *testing.T, args []string, exitAfterSignal } } -func runtimeProviderHelperCommand(args ...string) *exec.Cmd { - commandArgs := []string{"-test.run=^TestRuntimeProviderProcessHelper$", "--"} +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(), runtimeProviderHelperEnv+"=1") + cmd.Env = append(os.Environ(), driverHelperEnv+"=1") return cmd } -func runtimeProviderHelperArgs() []string { +func driverHelperArgs() []string { for i, arg := range os.Args { if arg == "--" { return os.Args[i+1:] @@ -227,14 +227,14 @@ func runtimeProviderHelperArgs() []string { return nil } -func waitForProviderProcessResult(t *testing.T, result <-chan providerProcessResult) providerProcessResult { +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("runtime provider process did not return") - return providerProcessResult{} + t.Fatal("driver process did not return") + return driverProcessResult{} } } diff --git a/cmd/internal/runtimeprovider/process_windows.go b/cmd/internal/projectdriver/process_windows.go similarity index 82% rename from cmd/internal/runtimeprovider/process_windows.go rename to cmd/internal/projectdriver/process_windows.go index c4fbc39e6..4f432dd9a 100644 --- a/cmd/internal/runtimeprovider/process_windows.go +++ b/cmd/internal/projectdriver/process_windows.go @@ -16,7 +16,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "context" @@ -31,7 +31,7 @@ import ( var ntResumeProcess = windows.NewLazySystemDLL("ntdll.dll").NewProc("NtResumeProcess") -func runProviderProcess(ctx context.Context, cmd *exec.Cmd) (ProcessStatus, error) { +func runDriverProcess(ctx context.Context, cmd *exec.Cmd) (ProcessStatus, error) { if err := ctx.Err(); err != nil { return ProcessStatus{}, err } @@ -50,7 +50,7 @@ func runProviderProcess(ctx context.Context, cmd *exec.Cmd) (ProcessStatus, erro ); err != nil { return ProcessStatus{}, err } - configureSuspendedProvider(cmd) + configureSuspendedDriver(cmd) if err := cmd.Start(); err != nil { return ProcessStatus{}, err } @@ -64,21 +64,21 @@ func runProviderProcess(ctx context.Context, cmd *exec.Cmd) (ProcessStatus, erro uint32(cmd.Process.Pid), ) if err != nil { - abortSuspendedProvider(cmd, 0) + abortSuspendedDriver(cmd, 0) return ProcessStatus{}, err } defer windows.CloseHandle(process) if err := windows.AssignProcessToJobObject(job, process); err != nil { - abortSuspendedProvider(cmd, process) + abortSuspendedDriver(cmd, process) return ProcessStatus{}, err } if err := ctx.Err(); err != nil { - terminateProviderJob(job, process) + terminateDriverJob(job, process) _ = cmd.Wait() return ProcessStatus{}, err } - if err := resumeProviderProcess(process); err != nil { - terminateProviderJob(job, process) + if err := resumeDriverProcess(process); err != nil { + terminateDriverJob(job, process) _ = cmd.Wait() return ProcessStatus{}, err } @@ -88,7 +88,7 @@ func runProviderProcess(ctx context.Context, cmd *exec.Cmd) (ProcessStatus, erro defer close(watchDone) select { case <-ctx.Done(): - terminateProviderJob(job, process) + terminateDriverJob(job, process) case <-done: } }() @@ -97,10 +97,10 @@ func runProviderProcess(ctx context.Context, cmd *exec.Cmd) (ProcessStatus, erro <-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 providerExitStatus(ctx, err) + return driverExitStatus(ctx, err) } -func configureSuspendedProvider(cmd *exec.Cmd) { +func configureSuspendedDriver(cmd *exec.Cmd) { if cmd.SysProcAttr == nil { cmd.SysProcAttr = &syscall.SysProcAttr{} } else { @@ -110,19 +110,19 @@ func configureSuspendedProvider(cmd *exec.Cmd) { cmd.SysProcAttr.CreationFlags |= windows.CREATE_SUSPENDED } -func resumeProviderProcess(process windows.Handle) error { +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 runtime provider: %w", status) + return fmt.Errorf("resume suspended driver: %w", status) } return nil } -func abortSuspendedProvider(cmd *exec.Cmd, process windows.Handle) { +func abortSuspendedDriver(cmd *exec.Cmd, process windows.Handle) { if process != 0 { _ = windows.TerminateProcess(process, 1) } else { @@ -131,7 +131,7 @@ func abortSuspendedProvider(cmd *exec.Cmd, process windows.Handle) { _ = cmd.Wait() } -func terminateProviderJob(job, process windows.Handle) { +func terminateDriverJob(job, process windows.Handle) { if err := windows.TerminateJobObject(job, 1); err != nil && process != 0 { _ = windows.TerminateProcess(process, 1) } diff --git a/cmd/internal/runtimeprovider/process_windows_test.go b/cmd/internal/projectdriver/process_windows_test.go similarity index 76% rename from cmd/internal/runtimeprovider/process_windows_test.go rename to cmd/internal/projectdriver/process_windows_test.go index 1cc66548b..c4c80a974 100644 --- a/cmd/internal/runtimeprovider/process_windows_test.go +++ b/cmd/internal/projectdriver/process_windows_test.go @@ -16,7 +16,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "bytes" @@ -34,13 +34,13 @@ import ( "golang.org/x/sys/windows" ) -func TestConfigureSuspendedProviderPreservesFlags(t *testing.T) { - cmd := exec.Command("provider.exe") +func TestConfigureSuspendedDriverPreservesFlags(t *testing.T) { + cmd := exec.Command("driver.exe") cmd.SysProcAttr = &windows.SysProcAttr{CreationFlags: windows.CREATE_NO_WINDOW} original := cmd.SysProcAttr - configureSuspendedProvider(cmd) + configureSuspendedDriver(cmd) if cmd.SysProcAttr == original { - t.Fatal("configureSuspendedProvider mutated caller-owned SysProcAttr") + t.Fatal("configureSuspendedDriver mutated caller-owned SysProcAttr") } want := uint32(windows.CREATE_NO_WINDOW | windows.CREATE_SUSPENDED) if got := cmd.SysProcAttr.CreationFlags; got != want { @@ -48,8 +48,8 @@ func TestConfigureSuspendedProviderPreservesFlags(t *testing.T) { } } -func TestRunProviderProcessWindows(t *testing.T) { - if os.Getenv("XGO_TEST_WINDOWS_PROVIDER_CHILD") == "1" { +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) @@ -64,30 +64,30 @@ func TestRunProviderProcessWindows(t *testing.T) { 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, "provider-stderr") + fmt.Fprint(os.Stderr, "driver-stderr") return } work := t.TempDir() - cmd := exec.Command(os.Args[0], "-test.run=^TestRunProviderProcessWindows$", "--", "a b", `c"d`) + cmd := exec.Command(os.Args[0], "-test.run=^TestRunDriverProcessWindows$", "--", "a b", `c"d`) cmd.Dir = work - cmd.Env = append(os.Environ(), "XGO_TEST_WINDOWS_PROVIDER_CHILD=1", "XGO_TEST_VALUE=present") - cmd.Stdin = strings.NewReader("provider-stdin") + 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 := runProviderProcess(context.Background(), cmd) + 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=provider-stdin", "env=present", "cwd=" + filepath.Base(work), `args=a b,c"d`} { + 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() != "provider-stderr" { + if stderr.String() != "driver-stderr" { t.Fatalf("stderr = %q", stderr.String()) } } diff --git a/cmd/internal/runtimeprovider/protocol.go b/cmd/internal/projectdriver/protocol.go similarity index 67% rename from cmd/internal/runtimeprovider/protocol.go rename to cmd/internal/projectdriver/protocol.go index 30d9ca9e1..9429d5b12 100644 --- a/cmd/internal/runtimeprovider/protocol.go +++ b/cmd/internal/projectdriver/protocol.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "fmt" @@ -22,40 +22,40 @@ import ( "strings" "unicode/utf16" - "github.com/goplus/mod/runtimeprotocol" + "github.com/goplus/mod/driverprotocol" "github.com/goplus/mod/xgomod" ) -func providerArgs(rt *Runtime, act action, policy BuildPolicy, output, finalOutput string, appArgs []string) ([]string, error) { - if rt.Protocol != protocolV1 { - return nil, fmt.Errorf("unsupported runtime provider protocol %q", rt.Protocol) +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 *runtimeprotocol.Pack - if rt.PackDir != "" || rt.PackIndex != "" { - if rt.PackDir == "" || rt.PackIndex == "" { - return nil, fmt.Errorf("runtime pack metadata must contain both directory and index") + 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 = &runtimeprotocol.Pack{Directory: rt.PackDir, IndexFile: rt.PackIndex} + pack = &driverprotocol.Pack{Directory: drv.PackDir, IndexFile: drv.PackIndex} } - request := runtimeprotocol.Request{ + request := driverprotocol.Request{ Version: protocolV1, Action: act, - Project: runtimeprotocol.Project{ - Dir: rt.ProjectDir, - File: rt.ProjectFile, - ModuleRoot: rt.ModuleRoot, - Extension: rt.ProjectExt, - FullExtension: rt.ProjectFullExt, + Project: driverprotocol.Project{ + Dir: drv.ProjectDir, + File: drv.ProjectFile, + ModuleRoot: drv.ModuleRoot, + Extension: drv.ProjectExt, + FullExtension: drv.ProjectFullExt, Pack: pack, }, - ProviderPackage: rt.ProviderPackage, - ProviderOrigin: rt.Origin, - Declaration: xgomod.FileIdentity{Path: rt.GoxMod, SHA256: rt.GoxModSHA256}, - Graph: runtimeprotocol.Graph{ - GoCommand: rt.Graph.GoCommand, - WorkDir: rt.Graph.WorkDir, - GoWork: rt.Graph.GoWork, - Flags: rt.Graph.goFlags(), + 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...), @@ -72,11 +72,11 @@ func providerArgs(rt *Runtime, act action, policy BuildPolicy, output, finalOutp if len(appArgs) != 0 { return nil, fmt.Errorf("build protocol cannot contain application arguments") } - request.Output = &runtimeprotocol.BuildOutput{Staging: output, Final: finalOutput} + request.Output = &driverprotocol.BuildOutput{Staging: output, Final: finalOutput} default: - return nil, fmt.Errorf("unsupported runtime provider action %q", act) + return nil, fmt.Errorf("unsupported driver action %q", act) } - return runtimeprotocol.Encode(request) + return driverprotocol.Encode(request) } func validateArgv(executable string, args, env []string) error { @@ -91,14 +91,14 @@ func validateArgv(executable string, args, env []string) error { n += 2*len(utf16.Encode([]rune(arg))) + 3 } if n > 30_000 { - return ErrRuntimeArgvTooLarge + return ErrDriverArgvTooLarge } envUnits := 1 for _, item := range env { envUnits += len(utf16.Encode([]rune(item))) + 1 } if envUnits > 32_767 { - return ErrRuntimeArgvTooLarge + return ErrDriverArgvTooLarge } return nil } @@ -115,7 +115,7 @@ func validateArgv(executable string, args, env []string) error { // 64-bit pointers, the largest supported host representation. n += 8 * (len(args) + len(env) + 3) if n > 128<<10 { - return ErrRuntimeArgvTooLarge + return ErrDriverArgvTooLarge } return nil } diff --git a/cmd/internal/runtimeprovider/protocol_test.go b/cmd/internal/projectdriver/protocol_test.go similarity index 72% rename from cmd/internal/runtimeprovider/protocol_test.go rename to cmd/internal/projectdriver/protocol_test.go index d98d129c4..299d5990d 100644 --- a/cmd/internal/runtimeprovider/protocol_test.go +++ b/cmd/internal/projectdriver/protocol_test.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "errors" @@ -23,12 +23,12 @@ import ( "testing" ) -func testRuntime() *Runtime { - return &Runtime{ - ProjectDir: "/project", - ProjectFile: "/project/main.foo", - ModuleRoot: "/project", - ProviderPackage: "example.test/framework/cmd/provider", +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{ @@ -52,19 +52,19 @@ func testRuntime() *Runtime { } } -func TestProviderArgsRun(t *testing.T) { - rt := testRuntime() - got, err := providerArgs(rt, actionRun, BuildPolicy{TrimPath: true, Verbose: true, Trace: true, KeepWork: true}, "", "", []string{"", "a b", "--"}) +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-runtime-v1", + "xgo-driver-v1", "run", "--project-dir=/project", "--project-file=/project/main.foo", "--module-root=/project", - "--provider-package=example.test/framework/cmd/provider", + "--driver-package=example.test/framework/cmd/driver", "--selected-path=example.test/framework", "--selected-version=v1.2.3", "--origin-main=false", @@ -98,12 +98,12 @@ func TestProviderArgsRun(t *testing.T) { } } -func TestProviderArgsBuildSelected(t *testing.T) { - rt := testRuntime() - rt.Origin.Replace = nil - rt.Origin.Selected.Dir = "/framework" - rt.Origin.Selected.GoMod = "/framework/go.mod" - got, err := providerArgs(rt, actionBuild, BuildPolicy{}, "/tmp/stage/game", "/out/game", nil) +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) } @@ -118,21 +118,21 @@ func TestProviderArgsBuildSelected(t *testing.T) { } } -func TestProviderArgsInvalid(t *testing.T) { - rt := testRuntime() - rt.Protocol = "v2" - if _, err := providerArgs(rt, actionRun, BuildPolicy{}, "", "", nil); err == nil { +func TestDriverArgsInvalid(t *testing.T) { + drv := testDriver() + drv.Protocol = "v2" + if _, err := driverArgs(drv, actionRun, BuildPolicy{}, "", "", nil); err == nil { t.Fatal("unsupported protocol succeeded") } - rt.Protocol = "v1" - if _, err := providerArgs(rt, actionBuild, BuildPolicy{}, "", "", nil); err == nil { + 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("provider", []string{big}, nil); !errors.Is(err, ErrRuntimeArgvTooLarge) { + if err := validateArgv("driver", []string{big}, nil); !errors.Is(err, ErrDriverArgvTooLarge) { t.Fatalf("validateArgv = %v", err) } } diff --git a/cmd/internal/runtimeprovider/resolve.go b/cmd/internal/projectdriver/resolve.go similarity index 78% rename from cmd/internal/runtimeprovider/resolve.go rename to cmd/internal/projectdriver/resolve.go index b892ec16f..9ec01e2f3 100644 --- a/cmd/internal/runtimeprovider/resolve.go +++ b/cmd/internal/projectdriver/resolve.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "context" @@ -38,7 +38,7 @@ type Resolver struct { // 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 runtime target as legacy and incorrectly dispatch it to GenGo. +// 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 { @@ -51,17 +51,17 @@ func NewResolver(ctx context.Context, cwd string, flags []string) (*Resolver, er return &Resolver{cwd: cwd, policy: policy, xgoVersion: env.Version()}, nil } -// BuildPolicy returns the validated runtime build policy. Callers must invoke -// it only after Resolve matched a runtime project. +// 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.validateRuntime(); err != nil { + 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) (*Runtime, error) { +func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Driver, error) { var ( kind TargetKind original string @@ -117,14 +117,14 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, kind, original, targetImportPath = TargetPackage, target.Path, target.Path lookupPath := target.Path if strings.Contains(lookupPath, "@") { - hasRuntime, err := r.versionedPackageHasRuntime(ctx, lookupPath) + hasDriver, err := r.versionedPackageHasDriver(ctx, lookupPath) if err != nil { return nil, err } - if !hasRuntime { + if !hasDriver { return nil, ErrNotHandled } - return nil, fmt.Errorf("runtime provider v1 does not support package target containing @version") + return nil, fmt.Errorf("driver v1 does not support package target containing @version") } if hasRecursivePattern(lookupPath) { unsupportedForm = "package pattern containing ..." @@ -146,16 +146,16 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, } if vendor { // Outside a workspace, an ordinary module with no class metadata - // cannot select a runtime provider. Avoid asking `go list -m all`, + // cannot select a driver. Avoid asking `go list -m all`, // which is unsupported in vendor mode, and preserve its legacy path. if !callerHasClass && r.policy.graph.GoWork == "off" { return nil, ErrNotHandled } - hasRuntime, probeErr := r.probeVendorPackage(ctx, preflightGoMod, preflightModule, lookupPath, recursivePattern) + hasDriver, probeErr := r.probeVendorPackage(ctx, preflightGoMod, preflightModule, lookupPath, recursivePattern) if probeErr != nil { return nil, probeErr } - if hasRuntime { + if hasDriver { return nil, vendorUnsupportedError(string(r.policy.graph.ModMode)) } return nil, ErrNotHandled @@ -176,11 +176,11 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, } return nil, ErrNotHandled } - // A package target may use runtime metadata from its own main/workspace + // A package target may use driver metadata from its own main/workspace // module. A non-main dependency is eligible only when the caller's exact // target modfile snapshot marked that logical module as an XGo class // dependency. Do not let retargeting discover an unmarked dependency's - // ambient gox.mod and expand the provider execution trust boundary. + // ambient gox.mod and expand the driver execution trust boundary. if !targetModule.Main && !classModuleMarked(callerGraph.ClassModules, targetModule.Selected.Path) { return nil, ErrNotHandled } @@ -207,46 +207,46 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, return nil, ErrNotHandled } if recursivePattern { - hasRuntime, err := patternContainsRuntimeProject(projectDir, module) + hasDriver, err := patternContainsDriverProject(projectDir, module) if err != nil { return nil, err } - if !hasRuntime { + if !hasDriver { return nil, ErrNotHandled } - return nil, fmt.Errorf("runtime provider v1 does not support %s", unsupportedForm) + return nil, fmt.Errorf("driver v1 does not support %s", unsupportedForm) } projectFile, info, candidates, err := findProjectFile(projectDir, module) if err != nil { return nil, err } - if info == nil || info.Project.Runtime == nil { + if info == nil || info.Project.Driver == nil { return nil, ErrNotHandled } if unsupportedForm != "" { - return nil, fmt.Errorf("runtime provider v1 does not support %s", unsupportedForm) + return nil, fmt.Errorf("driver v1 does not support %s", unsupportedForm) } if candidates != 1 { - return nil, fmt.Errorf("runtime project directory %q contains %d project files; exactly one is required", projectDir, candidates) + return nil, fmt.Errorf("driver-backed project directory %q contains %d project files; exactly one is required", projectDir, candidates) } if multiFile { - return nil, fmt.Errorf("runtime provider v1 does not support multiple source-file targets") + return nil, fmt.Errorf("driver v1 does not support multiple source-file targets") } if expectedFile != "" { same, err := sameFile(expectedFile, projectFile) if err != nil || !same { - return nil, fmt.Errorf("runtime file target %q is not the unique project file %q", original, projectFile) + return nil, fmt.Errorf("driver file target %q is not the unique project file %q", original, projectFile) } } if info.Origin == nil { - return nil, fmt.Errorf("runtime project %q has no module provenance", projectFile) + 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.Runtime.Protocol != protocolV1 { - return nil, fmt.Errorf("unsupported runtime provider protocol %q", project.Runtime.Protocol) + if project.Driver.Protocol != protocolV1 { + return nil, fmt.Errorf("unsupported driver protocol %q", project.Driver.Protocol) } packDir, packIndex, err := validatePack(projectDir, project.Pack) if err != nil { @@ -257,14 +257,14 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, if err != nil { return nil, err } - if os.Getenv("XGO_RUNTIME") == "off" { - return nil, ErrRuntimeDisabled + if os.Getenv("XGO_DRIVER") == "off" { + return nil, ErrDriverDisabled } - if os.Getenv(runtimeGuardEnv) != "" { - return nil, ErrRuntimeRecursive + if os.Getenv(driverGuardEnv) != "" { + return nil, ErrDriverRecursive } defaultName := defaultExecutableName(kind, projectDir, targetImportPath) - return &Runtime{ + return &Driver{ TargetKind: kind, OriginalTarget: original, TargetImportPath: targetImportPath, @@ -272,10 +272,10 @@ func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Runtime, ProjectDir: projectDir, ProjectFile: projectFile, ModuleRoot: graph.Target.Effective().Dir, - ProviderPackage: project.Runtime.Package, + DriverPackage: project.Driver.Package, Origin: origin, RequiredXGo: info.RequiredXGo, - Protocol: project.Runtime.Protocol, + Protocol: project.Driver.Protocol, ProjectExt: project.Ext, ProjectFullExt: project.FullExt, PackDir: packDir, @@ -291,17 +291,17 @@ func (r *Resolver) resolveTargetGraph(ctx context.Context, projectDir string, gr if graph.files == nil || !graph.files.hasOverlay() { return graph, nil } - hasRuntime, err := overlayRuntimeProjectMatch(projectDir, graph, recursive) + hasDriver, err := overlayDriverProjectMatch(projectDir, graph, recursive) if err != nil { return nil, err } - if !hasRuntime { + 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 runtime metadata absent from the physical tree. + // 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 { @@ -310,11 +310,11 @@ func (r *Resolver) resolveTargetGraph(ctx context.Context, projectDir string, gr } return nil, err } - hasRuntime, err := overlayRuntimeProjectMatch(projectDir, overlayGraph, recursive) + hasDriver, err := overlayDriverProjectMatch(projectDir, overlayGraph, recursive) if err != nil { return nil, err } - if !hasRuntime { + if !hasDriver { return nil, ErrNotHandled } return nil, unsupportedOverlayError(overlay) @@ -330,11 +330,11 @@ func (r *Resolver) resolveTargetGraph(ctx context.Context, projectDir string, gr return nil, ErrNotHandled } if vendor { - hasRuntime, err := r.probeVendorProject(projectDir, preflightModule, recursive) + hasDriver, err := r.probeVendorProject(projectDir, preflightModule, recursive) if err != nil { return nil, err } - if !hasRuntime { + if !hasDriver { return nil, ErrNotHandled } return nil, vendorUnsupportedError(string(r.policy.graph.ModMode)) diff --git a/cmd/internal/runtimeprovider/resolve_overlay.go b/cmd/internal/projectdriver/resolve_overlay.go similarity index 82% rename from cmd/internal/runtimeprovider/resolve_overlay.go rename to cmd/internal/projectdriver/resolve_overlay.go index 0d72431c3..010be2572 100644 --- a/cmd/internal/runtimeprovider/resolve_overlay.go +++ b/cmd/internal/projectdriver/resolve_overlay.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "context" @@ -101,13 +101,13 @@ func classModuleMarked(classModules []ResolvedModule, modulePath string) bool { return false } -// overlayRuntimeProjectMatch classifies against overlay metadata only. -// Runtime provider v1 rejects a positive overlay match before execution. -func overlayRuntimeProjectMatch(projectDir string, graph *effectiveGraph, recursive bool) (bool, error) { +// 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 := overlayRuntimeProjects(graph) + projects, err := overlayDriverProjects(graph) if err != nil { return false, err } @@ -115,30 +115,30 @@ func overlayRuntimeProjectMatch(projectDir string, graph *effectiveGraph, recurs return false, nil } if recursive { - return overlayWalkRuntimeProjects(projectDir, projects, graph.files) + return overlayWalkDriverProjects(projectDir, projects, graph.files) } names, err := graph.files.regularFileNames(projectDir) if err != nil { return false, err } for _, name := range names { - if runtimeProjectMatches(projects, name) { + if driverProjectMatches(projects, name) { return true, nil } } return false, nil } -func overlayRuntimeProjects(graph *effectiveGraph) ([]*modfile.Project, error) { +func overlayDriverProjects(graph *effectiveGraph) ([]*modfile.Project, error) { target := graph.Target.Effective() - loaded, err := loadRuntimeModuleView(graph.TargetModFile.Path, filepath.Join(target.Dir, "gox.mod"), graph.files) + 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 := loadRuntimeModuleView(effective.GoMod, filepath.Join(effective.Dir, "gox.mod"), graph.files) + 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) } @@ -147,7 +147,7 @@ func overlayRuntimeProjects(graph *effectiveGraph) ([]*modfile.Project, error) { return projects, nil } -func overlayWalkRuntimeProjects(root string, projects []*modfile.Project, view *graphFileView) (bool, error) { +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 @@ -161,8 +161,8 @@ func overlayWalkRuntimeProjects(root string, projects []*modfile.Project, view * return err } for _, name := range names { - if runtimeProjectMatches(projects, name) { - return errRuntimeProjectInPattern + if driverProjectMatches(projects, name) { + return errDriverProjectInPattern } } dirs, err := view.directoryNames(current) @@ -186,12 +186,12 @@ func overlayWalkRuntimeProjects(root string, projects []*modfile.Project, view * return nil } err := walk(root) - if errors.Is(err, errRuntimeProjectInPattern) { + if errors.Is(err, errDriverProjectInPattern) { return true, nil } return false, err } func unsupportedOverlayError(path string) error { - return fmt.Errorf("runtime provider v1 does not support flag -overlay=%s", path) + return fmt.Errorf("driver v1 does not support flag -overlay=%s", path) } diff --git a/cmd/internal/runtimeprovider/resolve_overlay_test.go b/cmd/internal/projectdriver/resolve_overlay_test.go similarity index 89% rename from cmd/internal/runtimeprovider/resolve_overlay_test.go rename to cmd/internal/projectdriver/resolve_overlay_test.go index 980f481db..7d8b518b4 100644 --- a/cmd/internal/runtimeprovider/resolve_overlay_test.go +++ b/cmd/internal/projectdriver/resolve_overlay_test.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "context" @@ -27,7 +27,7 @@ import ( "github.com/goplus/xgo/x/xgoprojs" ) -func TestResolveOverlayClassifiesRuntimeBeforePhysicalPreflight(t *testing.T) { +func TestResolveOverlayClassifiesDriverBeforePhysicalPreflight(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } @@ -37,15 +37,15 @@ func TestResolveOverlayClassifiesRuntimeBeforePhysicalPreflight(t *testing.T) { project := filepath.Join(app, "game") framework := filepath.Join(root, "framework") mustMkdirAll(t, project) - mustMkdirAll(t, filepath.Join(framework, "cmd", "provider")) + 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 -runtime v1 example.test/framework/cmd/provider +driver v1 example.test/framework/cmd/driver `) - mustWriteFile(t, filepath.Join(framework, "cmd", "provider", "main.go"), fakeProviderSource) - mustWriteFile(t, filepath.Join(project, "main.foo"), "// overlaid runtime project\n") + 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 @@ -77,20 +77,20 @@ replace example.test/framework => ../framework } graph, graphErr := loadEffectiveGraph(context.Background(), canonicalProject, resolver.policy.graph) if graphErr == nil { - matched, matchErr := overlayRuntimeProjectMatch(canonicalProject, graph, false) + matched, matchErr := overlayDriverProjectMatch(canonicalProject, graph, false) if matchErr != nil || !matched { - t.Fatalf("overlay classification = %v, %v; want runtime match", matched, matchErr) + 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 runtime Resolve() = %v, want explicit overlay rejection", err) + 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 runtime package Resolve() = %v, want explicit overlay rejection", err) + t.Fatalf("overlay driver package Resolve() = %v, want explicit overlay rejection", err) } } @@ -135,10 +135,10 @@ func TestResolveOverlayTargetsWithSyntheticDirectory(t *testing.T) { 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 -runtime v1 example.test/app/cmd/provider +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 runtime project\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 { diff --git a/cmd/internal/runtimeprovider/resolve_project.go b/cmd/internal/projectdriver/resolve_project.go similarity index 78% rename from cmd/internal/runtimeprovider/resolve_project.go rename to cmd/internal/projectdriver/resolve_project.go index 50e5a73d4..7b3fff751 100644 --- a/cmd/internal/runtimeprovider/resolve_project.go +++ b/cmd/internal/projectdriver/resolve_project.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "crypto/sha256" @@ -32,18 +32,18 @@ import ( "github.com/goplus/mod/xgomod" ) -const runtimeGuardEnv = "XGO_RUNTIME_GUARD" +const driverGuardEnv = "XGO_DRIVER_GUARD" -func runtimeGuard(projectDir, providerPackage string) string { - return sha256Bytes([]byte(projectDir + "\x00" + providerPackage)) +func driverGuard(projectDir, driverPackage string) string { + return sha256Bytes([]byte(projectDir + "\x00" + driverPackage)) } -// loadRuntimeModule preserves metadata fallback and reports unreadable optional files. -func loadRuntimeModule(goMod, goxMod string) (modload.Module, error) { - return loadRuntimeModuleView(goMod, goxMod, nil) +// loadDriverModule preserves metadata fallback and reports unreadable optional files. +func loadDriverModule(goMod, goxMod string) (modload.Module, error) { + return loadDriverModuleView(goMod, goxMod, nil) } -func loadRuntimeModuleView(goMod, goxMod string, view *graphFileView) (modload.Module, error) { +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{}{} @@ -71,10 +71,10 @@ func externalClassModule(module modload.Module) string { return "" } -func runtimeProjectMatches(projects []*modfile.Project, name string) bool { +func driverProjectMatches(projects []*modfile.Project, name string) bool { ext := modfile.ClassExt(name) for _, project := range projects { - if project != nil && project.Runtime != nil && project.IsProj(ext, name) { + if project != nil && project.Driver != nil && project.IsProj(ext, name) { return true } } @@ -83,7 +83,7 @@ func runtimeProjectMatches(projects []*modfile.Project, name string) bool { func loadResolvedClasses(graph *effectiveGraph) (*xgomod.Module, bool, error) { target := graph.Target.Effective() - loaded, err := loadRuntimeModuleView(graph.TargetModFile.Path, filepath.Join(target.Dir, "gox.mod"), graph.files) + loaded, err := loadDriverModuleView(graph.TargetModFile.Path, filepath.Join(target.Dir, "gox.mod"), graph.files) if err != nil { return nil, false, err } @@ -135,7 +135,7 @@ func findProjectFile(dir string, module *xgomod.Module) (string, *xgomod.Project continue } count++ - if classInfo.Project.Runtime != nil { + if classInfo.Project.Driver != nil { projectFile = filepath.Join(dir, entry.Name()) projectInfo = classInfo } @@ -143,22 +143,22 @@ func findProjectFile(dir string, module *xgomod.Module) (string, *xgomod.Project return projectFile, projectInfo, count, nil } -var errRuntimeProjectInPattern = errors.New("runtime project in pattern") +var errDriverProjectInPattern = errors.New("driver-backed project in pattern") -func patternContainsRuntimeProject(root string, module *xgomod.Module) (bool, error) { - return walkRuntimePattern(root, func(dir string) (bool, error) { +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.Runtime != nil, err + return info != nil && info.Project != nil && info.Project.Driver != nil, err }) } -func patternContainsRuntimeProjects(root string, projects []*modfile.Project) (bool, error) { - return walkRuntimePattern(root, func(dir string) (bool, error) { - return hasRuntimeProject(dir, projects) +func patternContainsDriverProjects(root string, projects []*modfile.Project) (bool, error) { + return walkDriverPattern(root, func(dir string) (bool, error) { + return hasDriverProject(dir, projects) }) } -func walkRuntimePattern(root string, matches func(string) (bool, error)) (bool, error) { +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 @@ -186,11 +186,11 @@ func walkRuntimePattern(root string, matches func(string) (bool, error)) (bool, return err } if matched { - return errRuntimeProjectInPattern + return errDriverProjectInPattern } return nil }) - if errors.Is(err, errRuntimeProjectInPattern) { + if errors.Is(err, errDriverProjectInPattern) { return true, nil } return false, err @@ -202,18 +202,18 @@ func validatePack(projectDir string, pack *modfile.Pack) (string, string, error) } dir := filepath.Clean(filepath.FromSlash(pack.Directory)) if pack.Directory == "" || filepath.IsAbs(dir) || dir == ".." || strings.HasPrefix(dir, ".."+string(filepath.Separator)) { - return "", "", fmt.Errorf("runtime pack directory %q is invalid", pack.Directory) + 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("runtime pack index %q is invalid", 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("runtime pack directory: %w", err) + return "", "", fmt.Errorf("driver pack directory: %w", err) } if !pathWithin(projectDir, canonical) { - return "", "", fmt.Errorf("runtime pack directory escapes the project") + return "", "", fmt.Errorf("driver pack directory escapes the project") } return filepath.ToSlash(dir), pack.IndexFile, nil } @@ -222,13 +222,13 @@ func declaringMetadata(origin ResolvedModule, snapshot xgomod.FileIdentity) (fil 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("runtime origin %q has an invalid declaring metadata snapshot", origin.Selected.Path) + 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("runtime origin %q has an invalid declaring metadata digest", origin.Selected.Path) + 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("runtime origin %q has an invalid declaring metadata digest", origin.Selected.Path) + 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 { diff --git a/cmd/internal/runtimeprovider/resolve_test.go b/cmd/internal/projectdriver/resolve_test.go similarity index 78% rename from cmd/internal/runtimeprovider/resolve_test.go rename to cmd/internal/projectdriver/resolve_test.go index 450aa2f9a..902e676a6 100644 --- a/cmd/internal/runtimeprovider/resolve_test.go +++ b/cmd/internal/projectdriver/resolve_test.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "bytes" @@ -35,7 +35,7 @@ import ( modzip "golang.org/x/mod/zip" ) -type runtimeFixture struct { +type driverFixture struct { root string app string project string @@ -43,7 +43,7 @@ type runtimeFixture struct { mainFile string } -func newRuntimeFixture(t *testing.T) runtimeFixture { +func newDriverFixture(t *testing.T) driverFixture { t.Helper() t.Setenv("GOWORK", "off") root := t.TempDir() @@ -51,7 +51,7 @@ func newRuntimeFixture(t *testing.T) runtimeFixture { project := filepath.Join(app, "game") framework := filepath.Join(root, "framework") mustMkdirAll(t, filepath.Join(project, "pack")) - mustMkdirAll(t, filepath.Join(framework, "cmd", "provider")) + mustMkdirAll(t, filepath.Join(framework, "cmd", "driver")) mustWriteFile(t, filepath.Join(app, "go.mod"), `module example.test/app go 1.25 @@ -66,16 +66,16 @@ replace example.test/framework => ../framework project main.foo Game example.test/framework class *.bar Worker pack pack index.data -runtime v1 example.test/framework/cmd/provider +driver v1 example.test/framework/cmd/driver `) - mustWriteFile(t, filepath.Join(framework, "cmd", "provider", "main.go"), fakeProviderSource) + 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 runtimeFixture{root: root, app: app, project: project, framework: framework, mainFile: mainFile} + return driverFixture{root: root, app: app, project: project, framework: framework, mainFile: mainFile} } -func (f runtimeFixture) resolver(t *testing.T, flags ...string) *Resolver { +func (f driverFixture) resolver(t *testing.T, flags ...string) *Resolver { t.Helper() resolver, err := NewResolver(context.Background(), f.app, flags) if err != nil { @@ -85,13 +85,13 @@ func (f runtimeFixture) resolver(t *testing.T, flags ...string) *Resolver { return resolver } -func resolveRuntime(t *testing.T, resolver *Resolver, target xgoprojs.Proj) *Runtime { +func resolveDriver(t *testing.T, resolver *Resolver, target xgoprojs.Proj) *Driver { t.Helper() - runtime, err := resolver.Resolve(context.Background(), target) + driver, err := resolver.Resolve(context.Background(), target) if err != nil { t.Fatal(err) } - return runtime + return driver } func TestLoadResolvedClassesWithoutClassMetadata(t *testing.T) { @@ -114,53 +114,53 @@ func TestLoadResolvedClassesWithoutClassMetadata(t *testing.T) { } } -func TestResolveRuntimeTargets(t *testing.T) { +func TestResolveDriverTargets(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } - fixture := newRuntimeFixture(t) + 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) { - rt := resolveRuntime(t, fixture.resolver(t), target) - if rt.ProjectDir != canonicalDir(t, fixture.project) || rt.ProjectFile != canonicalFile(t, fixture.mainFile) { - t.Fatalf("project identity = %q, %q", rt.ProjectDir, rt.ProjectFile) + 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 rt.ProviderPackage != "example.test/framework/cmd/provider" || rt.Protocol != "v1" { - t.Fatalf("provider = %#v", rt) + if drv.DriverPackage != "example.test/framework/cmd/driver" || drv.Protocol != "v1" { + t.Fatalf("driver = %#v", drv) } - if rt.Origin.Selected.Path != "example.test/framework" || rt.Origin.Selected.Version != "v1.2.3" || rt.Origin.Replace == nil { - t.Fatalf("origin = %#v", rt.Origin) + 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 rt.Origin.Selected.Dir != "" || rt.Origin.Replace.Path != canonicalDir(t, fixture.framework) { - t.Fatalf("replacement identity was flattened: %#v", rt.Origin) + if drv.Origin.Selected.Dir != "" || drv.Origin.Replace.Path != canonicalDir(t, fixture.framework) { + t.Fatalf("replacement identity was flattened: %#v", drv.Origin) } - if rt.PackDir != "pack" || rt.PackIndex != "index.data" || len(rt.GoxModSHA256) != 64 { - t.Fatalf("metadata = %#v", rt) + if drv.PackDir != "pack" || drv.PackIndex != "index.data" || len(drv.GoxModSHA256) != 64 { + t.Fatalf("metadata = %#v", drv) } }) } } -func TestResolveRejectsAnyNestedRuntimeDispatch(t *testing.T) { +func TestResolveRejectsAnyNestedDriverDispatch(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } - fixture := newRuntimeFixture(t) + fixture := newDriverFixture(t) resolver := fixture.resolver(t) target := &xgoprojs.DirProj{Dir: fixture.project} guards := map[string]string{ - "same provider": runtimeGuard(canonicalDir(t, fixture.project), "example.test/framework/cmd/provider"), - "different provider": runtimeGuard(canonicalDir(t, fixture.app), "example.test/other/cmd/provider"), + "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(runtimeGuardEnv, guard) - if _, err := resolver.Resolve(context.Background(), target); !errors.Is(err, ErrRuntimeRecursive) { - t.Fatalf("Resolve() = %v, want ErrRuntimeRecursive", err) + t.Setenv(driverGuardEnv, guard) + if _, err := resolver.Resolve(context.Background(), target); !errors.Is(err, ErrDriverRecursive) { + t.Fatalf("Resolve() = %v, want ErrDriverRecursive", err) } }) } @@ -170,22 +170,22 @@ func TestResolvePackageTargetKeepsCallerGraph(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } - fixture := newRuntimeFixture(t) + 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) - rt := resolveRuntime(t, resolver, &xgoprojs.PkgPathProj{Path: "example.test/framework/example"}) - if rt.ModuleRoot != canonicalDir(t, fixture.framework) || rt.Graph.WorkDir != canonicalDir(t, fixture.app) { - t.Fatalf("graph roots = module %q, work %q", rt.ModuleRoot, rt.Graph.WorkDir) + 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 rt.Origin.Main || rt.Origin.Replace == nil || rt.Origin.Selected.Version != "v1.2.3" { - t.Fatalf("caller replacement graph was lost: %#v", rt.Origin) + 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 := validateProvider(context.Background(), rt); err != nil { - t.Fatalf("provider validation switched graphs: %v", err) + if err := validateDriver(context.Background(), drv); err != nil { + t.Fatalf("driver validation switched graphs: %v", err) } } @@ -193,13 +193,13 @@ func TestResolveXGoOnlyPackageTargetWithModfile(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } - fixture := newRuntimeFixture(t) + 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, "runtime.mod"), string(data)) - resolver := fixture.resolver(t, "-modfile=runtime.mod") + 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) } @@ -212,9 +212,9 @@ func TestResolveVersionedPackageUsesRequestedVersionMetadata(t *testing.T) { 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\nruntime v1 example.test/framework/cmd/provider\n", - "cmd/provider/provider.go": "package provider\n", + "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{ @@ -231,17 +231,17 @@ func TestResolveVersionedPackageUsesRequestedVersionMetadata(t *testing.T) { "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": "// runtime project\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 runtime project\n", - "game/main.foo": "// XGo-only subdirectory runtime project\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 runtime project\n", + "main.foo": "// latest driver-backed project\n", "game/main.foo": "// parent project hidden by nested module\n", }, }) @@ -270,15 +270,15 @@ func TestResolveVersionedPackageUsesRequestedVersionMetadata(t *testing.T) { callerVersion string callerClass bool target string - wantRuntime bool + wantDriver bool }{ - {name: "legacy requested version ignores runtime caller graph", callerVersion: "v1.1.0", callerClass: true, target: "example.test/app@v1.0.0"}, - {name: "runtime requested version ignores legacy caller graph", callerVersion: "v1.0.0", target: "example.test/app@v1.1.0", wantRuntime: true}, - {name: "latest query uses selected version metadata", callerVersion: "v1.0.0", target: "example.test/app@latest", wantRuntime: true}, - {name: "XGo-only runtime version is classified", callerVersion: "v1.0.0", target: "example.test/app@v1.2.0", wantRuntime: true}, - {name: "XGo-only subdirectory uses parent module", callerVersion: "v1.0.0", target: "example.test/app/game@v1.2.0", wantRuntime: true}, + {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 runtime version remains legacy", callerVersion: "v1.0.0", target: "example.test/app/ordinary@v1.1.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() @@ -292,7 +292,7 @@ func TestResolveVersionedPackageUsesRequestedVersionMetadata(t *testing.T) { t.Fatal(err) } _, err = resolver.Resolve(context.Background(), &xgoprojs.PkgPathProj{Path: test.target}) - if test.wantRuntime { + 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) } @@ -340,7 +340,7 @@ func TestResolveVersionedPackageCanceledContext(t *testing.T) { } } -func TestLoadRuntimeModulePropagatesOptionalMetadataReadErrors(t *testing.T) { +func TestLoadDriverModulePropagatesOptionalMetadataReadErrors(t *testing.T) { dir := t.TempDir() goMod := filepath.Join(dir, "go.mod") goxMod := filepath.Join(dir, "gox.mod") @@ -348,18 +348,18 @@ func TestLoadRuntimeModulePropagatesOptionalMetadataReadErrors(t *testing.T) { 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 := loadRuntimeModule(goMod, goxMod); err == nil || !strings.Contains(err.Error(), "gox.mod") { - t.Fatalf("loadRuntimeModule() = %v, want explicit gox.mod read error", err) + 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 TestLoadRuntimeModuleFallsBackToGopModWhenGoxModIsAbsent(t *testing.T) { +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 := loadRuntimeModule(goMod, filepath.Join(dir, "gox.mod")) + loaded, err := loadDriverModule(goMod, filepath.Join(dir, "gox.mod")) if err != nil { t.Fatal(err) } @@ -387,11 +387,11 @@ func TestResolvePropagatesOptionalMetadataReadErrors(t *testing.T) { } } -func TestResolvePackageTargetRejectsUnmarkedDependencyRuntime(t *testing.T) { +func TestResolvePackageTargetRejectsUnmarkedDependencyDriver(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } - fixture := newRuntimeFixture(t) + 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") @@ -410,15 +410,15 @@ func TestResolvePackageTargetRejectsUnmarkedDependencyRuntime(t *testing.T) { 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 runtime = %v, want ErrNotHandled", err) + t.Fatalf("unmarked dependency driver = %v, want ErrNotHandled", err) } } -func TestResolveOrdinaryGoSubpackageInsideRuntimeModuleUsesLegacyPath(t *testing.T) { +func TestResolveOrdinaryGoSubpackageInsideDriverModuleUsesLegacyPath(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } - fixture := newRuntimeFixture(t) + fixture := newDriverFixture(t) ordinary := filepath.Join(fixture.framework, "ordinary") mustMkdirAll(t, ordinary) mustWriteFile(t, filepath.Join(ordinary, "main.go"), "package ordinary\n") @@ -429,41 +429,41 @@ func TestResolveOrdinaryGoSubpackageInsideRuntimeModuleUsesLegacyPath(t *testing } } -func TestUnsupportedPatternOnlyRejectsMatchedRuntimeTarget(t *testing.T) { +func TestUnsupportedPatternOnlyRejectsMatchedDriverTarget(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } - fixture := newRuntimeFixture(t) + 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 runtime project must be outside the pattern\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("runtime pattern error = %v", err) + 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 runtime project error = %v", err) + 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 runtime project error = %v", err) + t.Fatalf("package pattern containing driver-backed project error = %v", err) } } -func TestResolveRuntimeWithoutPack(t *testing.T) { +func TestResolveDriverWithoutPack(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } - fixture := newRuntimeFixture(t) + fixture := newDriverFixture(t) goxmod := filepath.Join(fixture.framework, "gox.mod") data, err := os.ReadFile(goxmod) if err != nil { @@ -473,11 +473,11 @@ func TestResolveRuntimeWithoutPack(t *testing.T) { if err := os.WriteFile(goxmod, data, 0644); err != nil { t.Fatal(err) } - rt := resolveRuntime(t, fixture.resolver(t), &xgoprojs.DirProj{Dir: fixture.project}) - if rt.PackDir != "" || rt.PackIndex != "" { - t.Fatalf("pack = %q, %q", rt.PackDir, rt.PackIndex) + 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 := providerArgs(rt, actionRun, BuildPolicy{}, "", "", nil) + args, err := driverArgs(drv, actionRun, BuildPolicy{}, "", "", nil) if err != nil { t.Fatal(err) } @@ -496,7 +496,7 @@ func TestDeclaringMetadataRejectsChangeAfterDiscovery(t *testing.T) { t.Fatal(err) } snapshot := xgomod.FileIdentity{Path: path, SHA256: sha256Bytes(original)} - origin := ResolvedModule{Selected: ModuleRef{Path: "example.test/provider", Dir: dir, GoMod: filepath.Join(dir, "go.mod")}, Main: true} + 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) } @@ -508,11 +508,11 @@ func TestDeclaringMetadataRejectsChangeAfterDiscovery(t *testing.T) { } } -func TestResolveRuntimeRejectsAmbiguousAndMultiFile(t *testing.T) { +func TestResolveDriverRejectsAmbiguousAndMultiFile(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } - fixture := newRuntimeFixture(t) + fixture := newDriverFixture(t) resolver := fixture.resolver(t) other := filepath.Join(fixture.project, "other.foo") mustWriteFile(t, other, "// another project\n") @@ -529,7 +529,7 @@ func TestResolveRuntimeRejectsAmbiguousAndMultiFile(t *testing.T) { } } -func TestResolveNonRuntimeNotHandled(t *testing.T) { +func TestResolveNonDriverNotHandled(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } @@ -607,7 +607,7 @@ func TestCheckRequiredXGoReportsDevelopmentCapability(t *testing.T) { if err == nil { t.Fatal("development build unexpectedly satisfied a newer capability") } - for _, want := range []string{"declaring module requires XGo 1.8.1", "(devel)", "runtime-provider capability 1.8.0"} { + 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) } @@ -618,10 +618,10 @@ func TestResolveUsesDeclaringModuleXGoRequirement(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } - fixture := newRuntimeFixture(t) + fixture := newDriverFixture(t) resolver := fixture.resolver(t) resolver.xgoVersion = "v1.8.0" - resolveRuntime(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) + resolveDriver(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) setFixtureRequiredXGo(t, fixture, "1.9.0") resolver = fixture.resolver(t) @@ -633,7 +633,7 @@ func TestResolveUsesDeclaringModuleXGoRequirement(t *testing.T) { resolver = fixture.resolver(t) resolver.xgoVersion = "v1.9.0" - resolveRuntime(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) + resolveDriver(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) } func mustRunGo(t *testing.T, dir, goWork string, args ...string) { @@ -664,7 +664,7 @@ func canonicalFile(t *testing.T, path string) string { return got } -const fakeProviderSource = `package main +const fakeDriverSource = `package main import ( "fmt" @@ -677,19 +677,19 @@ import ( ) func main() { - if marker := os.Getenv("FAKE_PROVIDER_MARKER"); marker != "" { + if marker := os.Getenv("FAKE_DRIVER_MARKER"); marker != "" { _ = os.WriteFile(marker, []byte("started"), 0600) } - if value := os.Getenv("FAKE_PROVIDER_EXIT"); value != "" { + if value := os.Getenv("FAKE_DRIVER_EXIT"); value != "" { code, _ := strconv.Atoi(value) os.Exit(code) } - if os.Getenv("FAKE_PROVIDER_BLOCK") == "1" { + if os.Getenv("FAKE_DRIVER_BLOCK") == "1" { for { time.Sleep(time.Hour) } } - if len(os.Args) < 3 || os.Args[1] != "xgo-runtime-v1" { + if len(os.Args) < 3 || os.Args[1] != "xgo-driver-v1" { os.Exit(90) } switch os.Args[2] { diff --git a/cmd/internal/runtimeprovider/resolve_vendor.go b/cmd/internal/projectdriver/resolve_vendor.go similarity index 94% rename from cmd/internal/runtimeprovider/resolve_vendor.go rename to cmd/internal/projectdriver/resolve_vendor.go index 39c568549..9c43116fd 100644 --- a/cmd/internal/runtimeprovider/resolve_vendor.go +++ b/cmd/internal/projectdriver/resolve_vendor.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "context" @@ -52,7 +52,7 @@ func (r *Resolver) preflightClassMetadataDetails(ctx context.Context, dir string if err != nil { return loaded, moduleGoMod, false, false, err } - loaded, err = loadRuntimeModule(identity.Path, filepath.Join(filepath.Dir(moduleGoMod), "gox.mod")) + loaded, err = loadDriverModule(identity.Path, filepath.Join(filepath.Dir(moduleGoMod), "gox.mod")) if err != nil { return loaded, moduleGoMod, false, false, err } @@ -90,7 +90,7 @@ func (r *Resolver) probeVendorPackage(ctx context.Context, moduleGoMod string, t return false, nil } if !pkg.Module.Main { - // An unmarked dependency cannot expand the provider trust boundary. + // An unmarked dependency cannot expand the driver trust boundary. return false, nil } module, err := normalizeListedModule(*pkg.Module) @@ -105,7 +105,7 @@ func (r *Resolver) probeVendorPackage(ctx context.Context, moduleGoMod string, t if !pathWithin(root, projectDir) { return false, fmt.Errorf("package target %q escapes module %q", importPath, module.Selected.Path) } - loaded, err := loadRuntimeModule(module.Effective().GoMod, filepath.Join(root, "gox.mod")) + loaded, err := loadDriverModule(module.Effective().GoMod, filepath.Join(root, "gox.mod")) if err != nil { return false, err } @@ -157,12 +157,12 @@ func (r *Resolver) probeVendorWorkspacePackage(ctx context.Context, importPath s return false, fmt.Errorf("%w: package target %q is not owned by a workspace member", vendorUnsupportedError(string(r.policy.graph.ModMode)), importPath) } - loaded, err := loadRuntimeModule(selected.goMod, filepath.Join(selected.root, "gox.mod")) + 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 runtime discovery", selected.modulePath, loaded.Path()) + 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) @@ -273,12 +273,12 @@ func (r *Resolver) matchVendorModule(projectDir string, module modload.Module, r func matchVendorProjects(dir string, projects []*modfile.Project, recursive bool) (bool, error) { if recursive { - return patternContainsRuntimeProjects(dir, projects) + return patternContainsDriverProjects(dir, projects) } - return hasRuntimeProject(dir, projects) + return hasDriverProject(dir, projects) } -func hasRuntimeProject(dir string, projects []*modfile.Project) (bool, error) { +func hasDriverProject(dir string, projects []*modfile.Project) (bool, error) { if len(projects) == 0 { return false, nil } @@ -297,7 +297,7 @@ func hasRuntimeProject(dir string, projects []*modfile.Project) (bool, error) { if !info.Mode().IsRegular() { continue } - if runtimeProjectMatches(projects, entry.Name()) { + if driverProjectMatches(projects, entry.Name()) { return true, nil } } @@ -395,5 +395,5 @@ func vendorUnsupportedError(mode string) error { } else { mode = "-mod=" + mode } - return fmt.Errorf("%w (%s); select -mod=readonly or -mod=mod explicitly", ErrRuntimeVendorUnsupported, mode) + return fmt.Errorf("%w (%s); select -mod=readonly or -mod=mod explicitly", ErrDriverVendorUnsupported, mode) } diff --git a/cmd/internal/runtimeprovider/resolve_vendor_test.go b/cmd/internal/projectdriver/resolve_vendor_test.go similarity index 80% rename from cmd/internal/runtimeprovider/resolve_vendor_test.go rename to cmd/internal/projectdriver/resolve_vendor_test.go index 53bb46cff..12cfd26fc 100644 --- a/cmd/internal/runtimeprovider/resolve_vendor_test.go +++ b/cmd/internal/projectdriver/resolve_vendor_test.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "context" @@ -29,15 +29,15 @@ import ( "github.com/goplus/xgo/x/xgoprojs" ) -func TestResolveRuntimeVendorFailsClosed(t *testing.T) { +func TestResolveDriverVendorFailsClosed(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } - fixture := newRuntimeFixture(t) + 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, ErrRuntimeVendorUnsupported) { + if !errors.Is(err, ErrDriverVendorUnsupported) { t.Fatalf("vendor error = %v", err) } } @@ -86,8 +86,8 @@ func TestResolveVendorClassifiesLegacyTargetsConservatively(t *testing.T) { } _, err = resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: project}) if test.wantUnsupported { - if !errors.Is(err, ErrRuntimeVendorUnsupported) { - t.Fatalf("external class vendor target = %v, want ErrRuntimeVendorUnsupported", err) + 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) @@ -96,7 +96,7 @@ func TestResolveVendorClassifiesLegacyTargetsConservatively(t *testing.T) { } } -func TestResolveRuntimeTargetStillRejectsVendor(t *testing.T) { +func TestResolveDriverTargetStillRejectsVendor(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } @@ -109,16 +109,16 @@ func TestResolveRuntimeTargetStillRejectsVendor(t *testing.T) { 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\nruntime v1 example.test/framework/cmd/provider\n") - mustWriteFile(t, filepath.Join(project, "main.foo"), "// runtime project\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, ErrRuntimeVendorUnsupported) { - t.Fatalf("runtime vendor target = %v", err) + if !errors.Is(err, ErrDriverVendorUnsupported) { + t.Fatalf("driver vendor target = %v", err) } } @@ -131,7 +131,7 @@ func TestResolveRealModuleVendorExternalClassFailsClosed(t *testing.T) { project := filepath.Join(app, "game") framework := filepath.Join(root, "framework") mustMkdirAll(t, project) - mustMkdirAll(t, filepath.Join(framework, "cmd", "provider")) + mustMkdirAll(t, filepath.Join(framework, "cmd", "driver")) mustWriteFile(t, filepath.Join(app, "go.mod"), `module example.test/app go 1.25 @@ -140,18 +140,18 @@ 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/provider\"\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 project main.foo Game example.test/framework -runtime v1 example.test/framework/cmd/provider +driver v1 example.test/framework/cmd/driver `) - mustWriteFile(t, filepath.Join(framework, "cmd", "provider", "provider.go"), "package provider\n") - mustWriteFile(t, filepath.Join(project, "main.foo"), "// runtime project\n") + 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", "provider", "provider.go")); err != nil { - t.Fatalf("real vendor snapshot omitted imported provider package: %v", err) + 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) @@ -166,8 +166,8 @@ runtime v1 example.test/framework/cmd/provider "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, ErrRuntimeVendorUnsupported) { - t.Fatalf("real vendored class target = %v, want ErrRuntimeVendorUnsupported", err) + if _, err := resolver.Resolve(context.Background(), target); !errors.Is(err, ErrDriverVendorUnsupported) { + t.Fatalf("real vendored class target = %v, want ErrDriverVendorUnsupported", err) } }) } @@ -206,14 +206,14 @@ func TestResolveRealWorkspaceVendorClassFailsClosed(t *testing.T) { project := filepath.Join(app, "game") framework := filepath.Join(root, "framework") mustMkdirAll(t, project) - mustMkdirAll(t, filepath.Join(framework, "cmd", "provider")) + 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/provider\"\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\nruntime v1 example.test/framework/cmd/provider\n") - mustWriteFile(t, filepath.Join(framework, "cmd", "provider", "provider.go"), "package provider\n") - mustWriteFile(t, filepath.Join(project, "main.foo"), "// runtime project\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 { @@ -224,8 +224,8 @@ func TestResolveRealWorkspaceVendorClassFailsClosed(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: project}); !errors.Is(err, ErrRuntimeVendorUnsupported) { - t.Fatalf("workspace vendored class target = %v, want ErrRuntimeVendorUnsupported", 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) } } @@ -235,26 +235,26 @@ func TestResolveWorkspaceVendorPackageUsesTargetModuleMetadata(t *testing.T) { } root := t.TempDir() caller := filepath.Join(root, "caller") - runtimeModule := filepath.Join(root, "runtime") - runtimeProject := filepath.Join(runtimeModule, "game") + 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, runtimeProject) + mustMkdirAll(t, driverProject) mustMkdirAll(t, legacyProject) mustWriteFile(t, filepath.Join(root, "go.work"), `go 1.25 use ( ./caller - ./runtime + ./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(runtimeModule, "go.mod"), "module example.test/runtime\n\ngo 1.25\n") - mustWriteFile(t, filepath.Join(runtimeModule, "gox.mod"), "xgo 1.8\nproject main.foo Game example.test/runtime\nruntime v1 example.test/runtime/cmd/provider\n") - mustWriteFile(t, filepath.Join(runtimeProject, "main.foo"), "// runtime project\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") @@ -264,18 +264,18 @@ use ( if err != nil { t.Fatal(err) } - if _, err := resolver.Resolve(context.Background(), &xgoprojs.PkgPathProj{Path: "example.test/runtime/game"}); !errors.Is(err, ErrRuntimeVendorUnsupported) { - t.Fatalf("workspace runtime package in vendor mode = %v, want ErrRuntimeVendorUnsupported", 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 runtime, err := resolver.probeVendorWorkspacePackage(context.Background(), "example.test/runtime/game", false); err != nil || !runtime { - t.Fatalf("workspace fallback runtime classification = %v, %v; want true", runtime, 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 runtime, err := resolver.probeVendorWorkspacePackage(context.Background(), "example.test/legacy/game", false); err != nil || runtime { - t.Fatalf("workspace fallback legacy classification = %v, %v; want false", runtime, 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) } } @@ -310,8 +310,8 @@ replace example.test/framework => ./missing-live-replacement if err != nil { t.Fatal(err) } - if _, err := resolver.probeVendorWorkspacePackage(context.Background(), "example.test/member/game", false); !errors.Is(err, ErrRuntimeVendorUnsupported) { - t.Fatalf("workspace external class classification = %v, want ErrRuntimeVendorUnsupported", 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) } } @@ -334,8 +334,8 @@ func TestProbeVendorWorkspacePackageUsesLongestMemberAndRejectsNestedModule(t *t 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\nruntime v1 example.test/shared/sub/cmd/provider\n") - mustWriteFile(t, filepath.Join(childProject, "main.foo"), "// runtime project in longest module match\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") @@ -345,8 +345,8 @@ func TestProbeVendorWorkspacePackageUsesLongestMemberAndRejectsNestedModule(t *t if err != nil { t.Fatal(err) } - if runtime, err := resolver.probeVendorWorkspacePackage(context.Background(), "example.test/shared/sub/game", false); err != nil || !runtime { - t.Fatalf("longest workspace member classification = %v, %v; want true", runtime, 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") @@ -361,9 +361,9 @@ func TestResolveVendorRecursivePatternFailsClosed(t *testing.T) { 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\nruntime v1 example.test/app/cmd/provider\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"), "// runtime project\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 { @@ -374,17 +374,17 @@ func TestResolveVendorRecursivePatternFailsClosed(t *testing.T) { "package": &xgoprojs.PkgPathProj{Path: "example.test/app/..."}, } { t.Run(name, func(t *testing.T) { - if _, err := resolver.Resolve(context.Background(), target); !errors.Is(err, ErrRuntimeVendorUnsupported) { - t.Fatalf("recursive vendor target = %v, want ErrRuntimeVendorUnsupported", err) + if _, err := resolver.Resolve(context.Background(), target); !errors.Is(err, ErrDriverVendorUnsupported) { + t.Fatalf("recursive vendor target = %v, want ErrDriverVendorUnsupported", err) } }) } } -func TestVendorRuntimeProbePropagatesFilesystemErrors(t *testing.T) { - projects := []*modfile.Project{{Runtime: &modfile.Runtime{Protocol: "v1", Package: "example.test/provider"}}} - if _, err := hasRuntimeProject(filepath.Join(t.TempDir(), "missing"), projects); err == nil { - t.Fatal("missing project directory was classified as no runtime") +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" { @@ -447,7 +447,7 @@ func TestEffectiveVendorModeMatchesGoCommandDefaults(t *testing.T) { } } -func TestResolveVendorIgnoresUnmarkedDependencyRuntime(t *testing.T) { +func TestResolveVendorIgnoresUnmarkedDependencyDriver(t *testing.T) { if testing.Short() { t.Skip("invokes the host Go command") } @@ -470,8 +470,8 @@ replace example.test/framework => ./framework // 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\nruntime v1 example.test/framework/cmd/provider\n") - mustWriteFile(t, filepath.Join(project, "main.foo"), "// unmarked dependency runtime project\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 { @@ -479,6 +479,6 @@ replace example.test/framework => ./framework } _, err = resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: project}) if !errors.Is(err, ErrNotHandled) { - t.Fatalf("unmarked vendor dependency runtime = %v, want ErrNotHandled", err) + t.Fatalf("unmarked vendor dependency driver = %v, want ErrNotHandled", err) } } diff --git a/cmd/internal/runtimeprovider/resolve_versioned.go b/cmd/internal/projectdriver/resolve_versioned.go similarity index 93% rename from cmd/internal/runtimeprovider/resolve_versioned.go rename to cmd/internal/projectdriver/resolve_versioned.go index f849d153d..7d2b469e7 100644 --- a/cmd/internal/runtimeprovider/resolve_versioned.go +++ b/cmd/internal/projectdriver/resolve_versioned.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "bytes" @@ -29,20 +29,20 @@ import ( "golang.org/x/mod/module" ) -// versionedPackageHasRuntime classifies the requested version in an isolated graph. +// versionedPackageHasDriver classifies the requested version in an isolated graph. // Caller graph metadata is deliberately not reused. -func (r *Resolver) versionedPackageHasRuntime(ctx context.Context, target string) (bool, error) { +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-runtime-version-probe-") + 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/runtimeprovider/versionprobe\n\ngo 1.25\n" + 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) } @@ -106,7 +106,7 @@ func (r *Resolver) versionedPackageHasRuntime(ctx context.Context, target string if err := listed.Validate(); err != nil { return false, fmt.Errorf("validate versioned package %q module: %w", target, err) } - probeMod = fmt.Sprintf("module xgo.dev/runtimeprovider/versionprobe\n\ngo 1.25\n\nrequire %s %s //xgo:class\n", listed.Selected.Path, listed.Selected.Version) + 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) } @@ -153,7 +153,7 @@ func (r *Resolver) versionedPackageHasRuntime(ctx context.Context, target string } module, hasClass, err := loadResolvedClasses(graph) if err != nil { - return false, fmt.Errorf("load versioned package %q runtime metadata: %w", target, err) + return false, fmt.Errorf("load versioned package %q driver metadata: %w", target, err) } if !hasClass { return false, nil @@ -162,7 +162,7 @@ func (r *Resolver) versionedPackageHasRuntime(ctx context.Context, target string if err != nil { return false, fmt.Errorf("classify versioned package %q: %w", target, err) } - return info != nil && info.Project != nil && info.Project.Runtime != nil, nil + return info != nil && info.Project != nil && info.Project.Driver != nil, nil } type versionedProbeState uint8 diff --git a/cmd/internal/runtimeprovider/signal_boundary_unix.go b/cmd/internal/projectdriver/signal_boundary_unix.go similarity index 76% rename from cmd/internal/runtimeprovider/signal_boundary_unix.go rename to cmd/internal/projectdriver/signal_boundary_unix.go index ee148d14b..7163dddc1 100644 --- a/cmd/internal/runtimeprovider/signal_boundary_unix.go +++ b/cmd/internal/projectdriver/signal_boundary_unix.go @@ -16,7 +16,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "context" @@ -27,15 +27,15 @@ import ( "syscall" ) -type runtimeSignalCause struct { +type driverSignalCause struct { signal syscall.Signal } -func (c runtimeSignalCause) Error() string { - return fmt.Sprintf("runtime interrupted by %s", c.signal) +func (c driverSignalCause) Error() string { + return fmt.Sprintf("driver interrupted by %s", c.signal) } -type runtimeSignalBoundary struct { +type driverSignalBoundary struct { ctx context.Context cancel context.CancelCauseFunc signals chan os.Signal @@ -45,12 +45,12 @@ type runtimeSignalBoundary struct { signal syscall.Signal } -func beginRuntimeSignalBoundary(parent context.Context) *runtimeSignalBoundary { +func beginDriverSignalBoundary(parent context.Context) *driverSignalBoundary { if parent == nil { parent = context.Background() } ctx, cancel := context.WithCancelCause(parent) - b := &runtimeSignalBoundary{ + 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) @@ -67,7 +67,7 @@ func beginRuntimeSignalBoundary(parent context.Context) *runtimeSignalBoundary { b.mu.Lock() if b.signal == 0 { b.signal = unixSignal - b.cancel(runtimeSignalCause{signal: unixSignal}) + b.cancel(driverSignalCause{signal: unixSignal}) } b.mu.Unlock() case <-b.done: @@ -78,9 +78,9 @@ func beginRuntimeSignalBoundary(parent context.Context) *runtimeSignalBoundary { return b } -func (b *runtimeSignalBoundary) Context() context.Context { return b.ctx } +func (b *driverSignalBoundary) Context() context.Context { return b.ctx } -func (b *runtimeSignalBoundary) Finish(status ProcessStatus, err error) (ProcessStatus, error) { +func (b *driverSignalBoundary) Finish(status ProcessStatus, err error) (ProcessStatus, error) { signal.Stop(b.signals) close(b.done) b.wait.Wait() diff --git a/cmd/internal/runtimeprovider/signal_boundary_unix_test.go b/cmd/internal/projectdriver/signal_boundary_unix_test.go similarity index 74% rename from cmd/internal/runtimeprovider/signal_boundary_unix_test.go rename to cmd/internal/projectdriver/signal_boundary_unix_test.go index b06f2a32b..384c6c0c7 100644 --- a/cmd/internal/runtimeprovider/signal_boundary_unix_test.go +++ b/cmd/internal/projectdriver/signal_boundary_unix_test.go @@ -16,7 +16,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "context" @@ -26,19 +26,19 @@ import ( "time" ) -func TestRuntimeSignalBoundaryRecordsSignalAndCancelsWork(t *testing.T) { - boundary := beginRuntimeSignalBoundary(context.Background()) +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("runtime signal boundary did not cancel work") + t.Fatal("driver signal boundary did not cancel work") } - cause, ok := context.Cause(boundary.Context()).(runtimeSignalCause) + cause, ok := context.Cause(boundary.Context()).(driverSignalCause) if !ok || cause.signal != syscall.SIGTERM { - t.Fatalf("context cause = %#v, want SIGTERM runtime signal", context.Cause(boundary.Context())) + 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 { diff --git a/cmd/internal/runtimeprovider/signal_boundary_windows.go b/cmd/internal/projectdriver/signal_boundary_windows.go similarity index 76% rename from cmd/internal/runtimeprovider/signal_boundary_windows.go rename to cmd/internal/projectdriver/signal_boundary_windows.go index 2a2ad9169..42c0a14d1 100644 --- a/cmd/internal/runtimeprovider/signal_boundary_windows.go +++ b/cmd/internal/projectdriver/signal_boundary_windows.go @@ -16,7 +16,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "context" @@ -25,7 +25,7 @@ import ( "sync" ) -type runtimeSignalBoundary struct { +type driverSignalBoundary struct { ctx context.Context cancel context.CancelFunc signals chan os.Signal @@ -35,12 +35,12 @@ type runtimeSignalBoundary struct { interrupt bool } -func beginRuntimeSignalBoundary(parent context.Context) *runtimeSignalBoundary { +func beginDriverSignalBoundary(parent context.Context) *driverSignalBoundary { if parent == nil { parent = context.Background() } ctx, cancel := context.WithCancel(parent) - b := &runtimeSignalBoundary{ctx: ctx, cancel: cancel, signals: make(chan os.Signal, 4), done: make(chan struct{})} + 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() { @@ -57,9 +57,9 @@ func beginRuntimeSignalBoundary(parent context.Context) *runtimeSignalBoundary { return b } -func (b *runtimeSignalBoundary) Context() context.Context { return b.ctx } +func (b *driverSignalBoundary) Context() context.Context { return b.ctx } -func (b *runtimeSignalBoundary) Finish(status ProcessStatus, err error) (ProcessStatus, error) { +func (b *driverSignalBoundary) Finish(status ProcessStatus, err error) (ProcessStatus, error) { signal.Stop(b.signals) close(b.done) b.wait.Wait() diff --git a/cmd/internal/runtimeprovider/status.go b/cmd/internal/projectdriver/status.go similarity index 81% rename from cmd/internal/runtimeprovider/status.go rename to cmd/internal/projectdriver/status.go index 670ed8373..a354ef316 100644 --- a/cmd/internal/runtimeprovider/status.go +++ b/cmd/internal/projectdriver/status.go @@ -14,12 +14,12 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import "os" -// Exit terminates a command handler with the exact provider status. Callers -// must invoke it only after all provider/output cleanup has completed. +// 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) diff --git a/cmd/internal/runtimeprovider/status_unix.go b/cmd/internal/projectdriver/status_unix.go similarity index 97% rename from cmd/internal/runtimeprovider/status_unix.go rename to cmd/internal/projectdriver/status_unix.go index b6b34c457..f069fdfa5 100644 --- a/cmd/internal/runtimeprovider/status_unix.go +++ b/cmd/internal/projectdriver/status_unix.go @@ -16,7 +16,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "os" diff --git a/cmd/internal/runtimeprovider/status_windows.go b/cmd/internal/projectdriver/status_windows.go similarity index 96% rename from cmd/internal/runtimeprovider/status_windows.go rename to cmd/internal/projectdriver/status_windows.go index 8711080e7..3b776491c 100644 --- a/cmd/internal/runtimeprovider/status_windows.go +++ b/cmd/internal/projectdriver/status_windows.go @@ -16,7 +16,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import "os" diff --git a/cmd/internal/runtimeprovider/types.go b/cmd/internal/projectdriver/types.go similarity index 67% rename from cmd/internal/runtimeprovider/types.go rename to cmd/internal/projectdriver/types.go index 6b8054ad4..f2417cc2f 100644 --- a/cmd/internal/runtimeprovider/types.go +++ b/cmd/internal/projectdriver/types.go @@ -14,36 +14,36 @@ * limitations under the License. */ -// Package runtimeprovider implements XGo's private runtime-provider dispatcher. -package runtimeprovider +// Package projectdriver implements XGo's private project-driver dispatcher. +package projectdriver import ( "errors" "io" "os" - "github.com/goplus/mod/runtimeprotocol" + "github.com/goplus/mod/driverprotocol" "github.com/goplus/mod/xgomod" ) -const protocolV1 = runtimeprotocol.Version1 +const protocolV1 = driverprotocol.Version1 var ( - // ErrNotHandled means that the target is not backed by a runtime provider. + // 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("runtime provider not configured") + ErrNotHandled = errors.New("driver not configured") - ErrRuntimeVendorUnsupported = errors.New("runtime providers do not support vendor mode") - ErrRuntimeDisabled = errors.New("runtime provider execution is disabled by XGO_RUNTIME=off") - ErrRuntimeRecursive = errors.New("recursive runtime provider invocation") - ErrRuntimeArgvTooLarge = errors.New("runtime provider argv and environment are too large") + 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 = runtimeprotocol.Action +type action = driverprotocol.Action const ( - actionRun = runtimeprotocol.ActionRun - actionBuild = runtimeprotocol.ActionBuild + actionRun = driverprotocol.ActionRun + actionBuild = driverprotocol.ActionBuild ) // TargetKind records the user-facing form of the resolved target. @@ -68,7 +68,7 @@ const ( ) // GraphPolicy is the exact module/workspace policy shared by discovery, -// validation, and provider construction. +// validation, and driver construction. type GraphPolicy struct { GoCommand string GoWork string @@ -76,11 +76,11 @@ type GraphPolicy struct { ModFile string Overlay string // WorkDir anchors all Go graph operations on both sides of the wire; - // provider execution itself still runs in ProjectDir. + // driver execution itself still runs in ProjectDir. WorkDir string } -// BuildPolicy is the runtime-safe subset of XGo/Go build flags. +// BuildPolicy is the driver-safe subset of XGo/Go build flags. type BuildPolicy struct { Verbose bool Trace bool @@ -89,8 +89,8 @@ type BuildPolicy struct { DisableBuildVCS bool } -// Runtime is the immutable discovery result passed to execution. -type Runtime struct { +// Driver is the immutable discovery result passed to execution. +type Driver struct { TargetKind TargetKind OriginalTarget string TargetImportPath string @@ -98,7 +98,7 @@ type Runtime struct { ProjectDir string ProjectFile string ModuleRoot string - ProviderPackage string + DriverPackage string Origin ResolvedModule RequiredXGo string Protocol string @@ -111,7 +111,7 @@ type Runtime struct { Graph GraphPolicy } -// Streams are inherited by the provider without using protocol files or stdin. +// Streams are inherited by the driver without using protocol files or stdin. type Streams struct { Stdin io.Reader Stdout io.Writer diff --git a/cmd/internal/runtimeprovider/version.go b/cmd/internal/projectdriver/version.go similarity index 74% rename from cmd/internal/runtimeprovider/version.go rename to cmd/internal/projectdriver/version.go index 5a0acf0e5..9888122ec 100644 --- a/cmd/internal/runtimeprovider/version.go +++ b/cmd/internal/projectdriver/version.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprovider +package projectdriver import ( "fmt" @@ -26,9 +26,9 @@ import ( var baseVersionRE = regexp.MustCompile(`(?:^|[^0-9])v?([0-9]+\.[0-9]+(?:\.[0-9]+)?(?:-[0-9A-Za-z.-]+)?)`) -// runtimeProviderCapability is the XGo capability level claimed by unversioned +// driverCapability is the XGo capability level claimed by unversioned // development builds. Released binaries continue to use env.Version(). -const runtimeProviderCapability = "1.8.0" +const driverCapability = "1.8.0" func checkRequiredXGo(required, current string) error { if required == "" { @@ -36,25 +36,25 @@ func checkRequiredXGo(required, current string) error { } requiredSemver, ok := normalizeXGoVersion(required) if !ok { - return fmt.Errorf("invalid required XGo version %q (xgo build %s)", required, describeRuntimeProviderVersion(current)) + return fmt.Errorf("invalid required XGo version %q (xgo build %s)", required, describeDriverVersion(current)) } - currentSemver, ok := comparableRuntimeProviderVersion(current) + currentSemver, ok := comparableDriverVersion(current) if !ok || semver.Compare(currentSemver, requiredSemver) < 0 { - return fmt.Errorf("runtime project's declaring module requires XGo %s, but xgo build is %s", required, describeRuntimeProviderVersion(current)) + return fmt.Errorf("declaring module requires XGo %s, but xgo build is %s", required, describeDriverVersion(current)) } return nil } -func comparableRuntimeProviderVersion(version string) (string, bool) { +func comparableDriverVersion(version string) (string, bool) { if isDevelopmentVersion(version) { - return normalizeXGoVersion(runtimeProviderCapability) + return normalizeXGoVersion(driverCapability) } return comparableCurrentVersion(version) } -func describeRuntimeProviderVersion(version string) string { +func describeDriverVersion(version string) string { if isDevelopmentVersion(version) { - return fmt.Sprintf("%s (runtime-provider capability %s)", version, runtimeProviderCapability) + return fmt.Sprintf("%s (driver capability %s)", version, driverCapability) } return version } @@ -82,7 +82,7 @@ func comparableCurrentVersion(version string) (string, bool) { return normalized, true } // Display-form versions may include a comparable semantic base. Unversioned - // development builds are handled by comparableRuntimeProviderVersion. + // development builds are handled by comparableDriverVersion. match := baseVersionRE.FindStringSubmatch(version) if len(match) != 2 { return "", false diff --git a/cmd/internal/run/run.go b/cmd/internal/run/run.go index 4e19228e1..0ac5ff803 100644 --- a/cmd/internal/run/run.go +++ b/cmd/internal/run/run.go @@ -26,7 +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/runtimeprovider" + "github.com/goplus/xgo/cmd/internal/projectdriver" "github.com/goplus/xgo/tool" "github.com/goplus/xgo/x/gocmd" "github.com/goplus/xgo/x/xgoprojs" @@ -78,24 +78,24 @@ func runCmd(cmd *base.Command, args []string) { } noChdir := *flagNoChdir - runtimeFlags := append([]string(nil), pass.Args...) + driverFlags := append([]string(nil), pass.Args...) if *flagAsm { - runtimeFlags = append(runtimeFlags, "-asm=true") + driverFlags = append(driverFlags, "-asm=true") } if *flagNoChdir { - runtimeFlags = append(runtimeFlags, "-nc=true") + driverFlags = append(driverFlags, "-nc=true") } if *flagProf { - runtimeFlags = append(runtimeFlags, "-prof=true") + driverFlags = append(driverFlags, "-prof=true") } - runtimeResult, runtimeErr := tryRuntime(proj, args, runtimeFlags) - if runtimeErr != nil { - fmt.Fprintln(os.Stderr, runtimeErr) + driverResult, driverErr := tryDriver(proj, args, driverFlags) + if driverErr != nil { + fmt.Fprintln(os.Stderr, driverErr) os.Exit(1) } - if runtimeResult.Handled { - if runtimeResult.Status.Signaled || runtimeResult.Status.Code != 0 { - runtimeprovider.Exit(runtimeResult.Status) + if driverResult.Handled { + if driverResult.Status.Signaled || driverResult.Status.Code != 0 { + projectdriver.Exit(driverResult.Status) } return } @@ -117,8 +117,8 @@ func runCmd(cmd *base.Command, args []string) { run(proj, args, !noChdir, conf, confCmd) } -func tryRuntime(proj xgoprojs.Proj, args, flags []string) (runtimeprovider.DispatchResult, error) { - return runtimeprovider.TryRun(context.Background(), "", proj, flags, args, runtimeprovider.Streams{}) +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) { diff --git a/doc/gox.mod.md b/doc/gox.mod.md index fe3f286d1..3982c15e3 100644 --- a/doc/gox.mod.md +++ b/doc/gox.mod.md @@ -222,27 +222,27 @@ mygame/ --- -## Runtime providers +## Project drivers -A framework may delegate execution to a verified provider executable: +A framework may delegate project execution to a verified driver executable: ```text -runtime v1 example.com/framework/cmd/provider +driver v1 example.com/framework/cmd/driver ``` -The provider must be a `main` package inside the declaring module. XGo resolves +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 provider protocol. +and `xgo install` use the same driver protocol. The protocol version and the declaring module's `xgo` requirement are independent. -`runtime v1` identifies the provider contract, first supported by XGo 1.8.0; +`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. -Runtime providers are intentionally fail-closed: vendor and overlay-backed -runtime projects are rejected, and `XGO_RUNTIME=off` disables dispatch. Keep -the provider package and its `gox.mod`/`gop.mod` in the framework module. +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. ---