From 0dab552c177d3508add0d4c1461f5132608f55a6 Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Sat, 22 Aug 2026 12:01:20 +0800 Subject: [PATCH] feat(driver): add project driver v1 protocol and graph provenance --- README.md | 11 + driverprotocol/action_test.go | 54 +++ driverprotocol/argv.go | 204 +++++++++++ driverprotocol/argv_options.go | 118 +++++++ driverprotocol/protocol.go | 92 +++++ driverprotocol/protocol_parse_test.go | 272 +++++++++++++++ driverprotocol/protocol_roundtrip_test.go | 167 +++++++++ driverprotocol/protocol_test.go | 68 ++++ driverprotocol/protocol_validation_test.go | 380 +++++++++++++++++++++ driverprotocol/request_validate.go | 126 +++++++ driverprotocol/validation.go | 142 ++++++++ modfile/rule.go | 55 ++- modfile/rule_test.go | 73 ++++ modload/module.go | 62 +++- modload/module_test.go | 89 +++++ xgomod/classfile.go | 10 +- xgomod/classfile_provenance.go | 181 ++++++++++ xgomod/module.go | 1 + xgomod/path.go | 33 ++ xgomod/path_test.go | 43 +++ xgomod/resolved.go | 90 +++++ xgomod/resolved_graph.go | 75 ++++ xgomod/resolved_graph_test.go | 173 ++++++++++ xgomod/resolved_identity.go | 65 ++++ xgomod/resolved_import.go | 63 ++++ xgomod/resolved_import_test.go | 291 ++++++++++++++++ xgomod/resolved_module_test.go | 361 ++++++++++++++++++++ xgomod/resolved_receiver_test.go | 373 ++++++++++++++++++++ xgomod/resolved_source.go | 149 ++++++++ xgomod/resolved_test.go | 136 ++++++++ xgomod/resolved_validation.go | 104 ++++++ 31 files changed, 4048 insertions(+), 13 deletions(-) create mode 100644 driverprotocol/action_test.go create mode 100644 driverprotocol/argv.go create mode 100644 driverprotocol/argv_options.go create mode 100644 driverprotocol/protocol.go create mode 100644 driverprotocol/protocol_parse_test.go create mode 100644 driverprotocol/protocol_roundtrip_test.go create mode 100644 driverprotocol/protocol_test.go create mode 100644 driverprotocol/protocol_validation_test.go create mode 100644 driverprotocol/request_validate.go create mode 100644 driverprotocol/validation.go create mode 100644 xgomod/classfile_provenance.go create mode 100644 xgomod/path.go create mode 100644 xgomod/path_test.go create mode 100644 xgomod/resolved.go create mode 100644 xgomod/resolved_graph.go create mode 100644 xgomod/resolved_graph_test.go create mode 100644 xgomod/resolved_identity.go create mode 100644 xgomod/resolved_import.go create mode 100644 xgomod/resolved_import_test.go create mode 100644 xgomod/resolved_module_test.go create mode 100644 xgomod/resolved_receiver_test.go create mode 100644 xgomod/resolved_source.go create mode 100644 xgomod/resolved_test.go create mode 100644 xgomod/resolved_validation.go diff --git a/README.md b/README.md index 32019b1..a9f953f 100644 --- a/README.md +++ b/README.md @@ -8,3 +8,14 @@ mod - Module support for Go/XGo [![XGo](https://img.shields.io/badge/project-XGo-blue.svg)](https://github.com/goplus/xgo) This repository holds packages for writing tools that work directly with Go/XGo module mechanics. That is, it is for direct manipulation of Go/XGo modules themselves. + +## Project drivers + +Framework metadata may attach a driver to the nearest preceding project: + +```text +project main.spx Game example.com/framework math +driver v1 example.com/framework/cmd/xgodriver +``` + +The protocol must match `v[1-9][0-9]*`; `driver` is a single-line, non-duplicate directive and has no block form. `driverprotocol` defines the request model and argv codec, but parsing and structural validation do not authenticate files. Consumers must reject non-canonical or symlinked declaration paths and re-hash declaration bytes before trusting the supplied SHA-256 identity. `xgomod.ImportClassesResolved` validates the target snapshot and ordered class-module provenance without rediscovering the graph. diff --git a/driverprotocol/action_test.go b/driverprotocol/action_test.go new file mode 100644 index 0000000..3738b30 --- /dev/null +++ b/driverprotocol/action_test.go @@ -0,0 +1,54 @@ +/* + * 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 driverprotocol + +import ( + "fmt" + "testing" +) + +func TestActionValidate(t *testing.T) { + tests := []struct { + action Action + valid bool + }{ + {ActionRun, true}, + {ActionBuild, true}, + {"", false}, + {"publish", false}, + } + for _, test := range tests { + t.Run(fmt.Sprintf("%q", test.action), func(t *testing.T) { + err := test.action.Validate() + if (err == nil) != test.valid { + t.Fatalf("Action(%q).Validate() = %v, valid = %v", test.action, err, test.valid) + } + if !test.valid { + request := testRequest() + request.Action = test.action + requestErr := request.Validate() + if requestErr == nil || requestErr.Error() != err.Error() { + t.Fatalf("Request.Validate() = %v, Action.Validate() = %v", requestErr, err) + } + _, parseErr := Parse([]string{PreambleV1, string(test.action)}) + if parseErr == nil || parseErr.Error() != err.Error() { + t.Fatalf("Parse() = %v, Action.Validate() = %v", parseErr, err) + } + } + }) + } +} diff --git a/driverprotocol/argv.go b/driverprotocol/argv.go new file mode 100644 index 0000000..23f7f28 --- /dev/null +++ b/driverprotocol/argv.go @@ -0,0 +1,204 @@ +/* + * 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 driverprotocol + +import ( + "fmt" + + "github.com/goplus/mod/xgomod" +) + +// Encode returns deterministic argv following the driver executable. +func Encode(request Request) ([]string, error) { + if err := request.Validate(); err != nil { + return nil, err + } + args := []string{ + PreambleV1, + string(request.Action), + option("project-dir", request.Project.Dir), + option("project-file", request.Project.File), + option("module-root", request.Project.ModuleRoot), + option("driver-package", request.DriverPackage), + option("selected-path", request.DriverOrigin.Selected.Path), + option("selected-version", request.DriverOrigin.Selected.Version), + option("origin-main", fmt.Sprint(request.DriverOrigin.Main)), + } + if request.DriverOrigin.Replace == nil { + args = append(args, + option("selected-dir", request.DriverOrigin.Selected.Dir), + option("selected-gomod", request.DriverOrigin.Selected.GoMod), + ) + } else { + replacement := request.DriverOrigin.Replace + args = append(args, + option("replace-path", replacement.Path), + option("replace-version", replacement.Version), + option("replace-dir", replacement.Dir), + option("replace-gomod", replacement.GoMod), + ) + } + args = append(args, + option("project-ext", request.Project.Extension), + option("project-full-ext", request.Project.FullExtension), + ) + if request.Project.Pack != nil { + args = append(args, + option("pack-dir", request.Project.Pack.Directory), + option("pack-index", request.Project.Pack.IndexFile), + ) + } + args = append(args, + option("declaration-file", request.Declaration.Path), + option("declaration-sha256", request.Declaration.SHA256), + option("go-command", request.Graph.GoCommand), + option("graph-work-dir", request.Graph.WorkDir), + option("go-work", request.Graph.GoWork), + ) + for _, flag := range request.Graph.Flags { + args = append(args, option("graph-flag", flag)) + } + for _, flag := range request.BuildFlags { + args = append(args, option("build-flag", flag)) + } + if request.Action == ActionRun { + args = append(args, "--") + args = append(args, request.ApplicationArgs...) + } else { + args = append(args, + option("output", request.Output.Staging), + option("final-output", request.Output.Final), + ) + } + return args, nil +} + +// Parse decodes driver argv and rejects invalid structure; it does not authenticate referenced files. +func Parse(args []string) (Request, error) { + var request Request + if len(args) < 2 { + return request, fmt.Errorf("driverprotocol: request requires preamble and action") + } + if args[0] != PreambleV1 { + return request, fmt.Errorf("driverprotocol: unsupported preamble %q", args[0]) + } + request.Version = Version1 + request.Action = Action(args[1]) + if err := request.Action.Validate(); err != nil { + return Request{}, err + } + + optionArgs := args[2:] + if request.Action == ActionRun { + delimiter := -1 + for i, arg := range optionArgs { + if arg == "--" { + delimiter = i + break + } + } + if delimiter < 0 { + return Request{}, fmt.Errorf("driverprotocol: run requires -- before application arguments") + } + request.ApplicationArgs = append([]string(nil), optionArgs[delimiter+1:]...) + optionArgs = optionArgs[:delimiter] + } else { + for _, arg := range optionArgs { + if arg == "--" { + return Request{}, fmt.Errorf("driverprotocol: build does not accept -- or positional arguments") + } + } + } + + raw, err := parseOptions(optionArgs) + if err != nil { + return Request{}, err + } + for _, spec := range singularOptionSpecs { + if _, ok := raw.values[spec.name]; spec.required && !ok { + return Request{}, fmt.Errorf("driverprotocol: option --%s is required", spec.name) + } + } + + request.Project = Project{ + Dir: raw.values["project-dir"], + File: raw.values["project-file"], + ModuleRoot: raw.values["module-root"], + Extension: raw.values["project-ext"], + FullExtension: raw.values["project-full-ext"], + } + request.Declaration = xgomod.FileIdentity{ + Path: raw.values["declaration-file"], SHA256: raw.values["declaration-sha256"], + } + hasPack, completePack := optionGroup(raw.values, "pack-dir", "pack-index") + if hasPack && !completePack { + return Request{}, fmt.Errorf("driverprotocol: pack options must be supplied as a complete group") + } + if hasPack { + request.Project.Pack = &Pack{Directory: raw.values["pack-dir"], IndexFile: raw.values["pack-index"]} + } + + request.DriverPackage = raw.values["driver-package"] + request.DriverOrigin = xgomod.ResolvedModule{ + Selected: xgomod.ModuleRef{ + Path: raw.values["selected-path"], + Version: raw.values["selected-version"], + }, + } + switch raw.values["origin-main"] { + case "true": + request.DriverOrigin.Main = true + case "false": + default: + return Request{}, fmt.Errorf("driverprotocol: invalid --origin-main %q: expected true or false", raw.values["origin-main"]) + } + request.DriverOrigin.Selected.Dir = raw.values["selected-dir"] + request.DriverOrigin.Selected.GoMod = raw.values["selected-gomod"] + hasSelected, _ := optionGroup(raw.values, "selected-dir", "selected-gomod") + hasReplacement, completeReplacement := optionGroup(raw.values, replacementOptions...) + if hasReplacement && !completeReplacement { + return Request{}, fmt.Errorf("driverprotocol: replacement options must be supplied as a complete group") + } + if hasReplacement && hasSelected { + return Request{}, fmt.Errorf("driverprotocol: origin with replacement forbids --selected-dir and --selected-gomod") + } + if hasReplacement { + request.DriverOrigin.Replace = &xgomod.ModuleRef{ + Path: raw.values["replace-path"], + Version: raw.values["replace-version"], + Dir: raw.values["replace-dir"], + GoMod: raw.values["replace-gomod"], + } + } + + request.Graph = Graph{ + GoCommand: raw.values["go-command"], + WorkDir: raw.values["graph-work-dir"], + GoWork: raw.values["go-work"], + Flags: append([]string(nil), raw.graphFlags...), + } + request.BuildFlags = append([]string(nil), raw.buildFlags...) + output, hasOutput := raw.values["output"] + final, hasFinal := raw.values["final-output"] + if hasOutput || hasFinal { + request.Output = &BuildOutput{Staging: output, Final: final} + } + if err := request.Validate(); err != nil { + return Request{}, err + } + return request, nil +} diff --git a/driverprotocol/argv_options.go b/driverprotocol/argv_options.go new file mode 100644 index 0000000..a785da1 --- /dev/null +++ b/driverprotocol/argv_options.go @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package driverprotocol + +import ( + "fmt" + "strings" +) + +type optionSpec struct { + name string + required bool +} + +// singularOptionSpecs is both the accepted singular option set and the stable +// order in which missing required options are reported. +var singularOptionSpecs = []optionSpec{ + {"project-dir", true}, + {"project-file", true}, + {"module-root", true}, + {"driver-package", true}, + {"selected-path", true}, + {"selected-version", true}, + {"origin-main", true}, + {"selected-dir", false}, + {"selected-gomod", false}, + {"replace-path", false}, + {"replace-version", false}, + {"replace-dir", false}, + {"replace-gomod", false}, + {"project-ext", true}, + {"project-full-ext", true}, + {"pack-dir", false}, + {"pack-index", false}, + {"declaration-file", true}, + {"declaration-sha256", true}, + {"go-command", true}, + {"graph-work-dir", true}, + {"go-work", true}, + {"output", false}, + {"final-output", false}, +} + +var replacementOptions = []string{ + "replace-path", + "replace-version", + "replace-dir", + "replace-gomod", +} + +type rawOptions struct { + values map[string]string + graphFlags []string + buildFlags []string +} + +func parseOptions(args []string) (rawOptions, error) { + raw := rawOptions{values: make(map[string]string)} + for _, arg := range args { + if !strings.HasPrefix(arg, "--") || arg == "--" { + return rawOptions{}, fmt.Errorf("driverprotocol: unexpected positional argument %q", arg) + } + name, value, ok := strings.Cut(strings.TrimPrefix(arg, "--"), "=") + if !ok || name == "" { + return rawOptions{}, fmt.Errorf("driverprotocol: option %q must use --name=value", arg) + } + switch name { + case "graph-flag": + raw.graphFlags = append(raw.graphFlags, value) + case "build-flag": + raw.buildFlags = append(raw.buildFlags, value) + default: + if !isSingularOption(name) { + return rawOptions{}, fmt.Errorf("driverprotocol: unknown option --%s", name) + } + if _, duplicate := raw.values[name]; duplicate { + return rawOptions{}, fmt.Errorf("driverprotocol: option --%s may not be repeated", name) + } + raw.values[name] = value + } + } + return raw, nil +} + +func isSingularOption(name string) bool { + for _, spec := range singularOptionSpecs { + if spec.name == name { + return true + } + } + return false +} + +func optionGroup(values map[string]string, names ...string) (present, complete bool) { + complete = true + for _, name := range names { + _, ok := values[name] + present = present || ok + complete = complete && ok + } + return +} + +func option(name, value string) string { return "--" + name + "=" + value } diff --git a/driverprotocol/protocol.go b/driverprotocol/protocol.go new file mode 100644 index 0000000..4cb7845 --- /dev/null +++ b/driverprotocol/protocol.go @@ -0,0 +1,92 @@ +/* + * 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 driverprotocol defines the driver request model and argv codec. +// Validation is structural; consumers verify identity-bearing paths. +package driverprotocol + +import ( + "fmt" + + "github.com/goplus/mod/xgomod" +) + +const ( + // Version1 is the gox.mod driver protocol value. + Version1 = "v1" + // PreambleV1 is the first argv element passed to a v1 driver. + PreambleV1 = "xgo-driver-v1" +) + +// Action identifies the requested driver operation. +type Action string + +const ( + ActionRun Action = "run" + ActionBuild Action = "build" +) + +// Validate reports whether the action is supported by the v1 protocol. +func (a Action) Validate() error { + if a != ActionRun && a != ActionBuild { + return fmt.Errorf("driverprotocol: unsupported action %q", a) + } + return nil +} + +// Pack describes optional project pack metadata. +type Pack struct { + Directory string + IndexFile string +} + +// Project is the project snapshot discovered by XGo. +type Project struct { + Dir string + File string + ModuleRoot string + Extension string + FullExtension string + Pack *Pack +} + +// Graph carries the Go command and workspace policy used for discovery. +type Graph struct { + GoCommand string + WorkDir string + GoWork string + Flags []string +} + +// BuildOutput contains staging and final output paths. +type BuildOutput struct { + Staging string + Final string +} + +// Request is one driver request; run has no Output, build has no ApplicationArgs. +type Request struct { + Version string + Action Action + Project Project + DriverPackage string + DriverOrigin xgomod.ResolvedModule + Declaration xgomod.FileIdentity + Graph Graph + BuildFlags []string + Output *BuildOutput + ApplicationArgs []string +} diff --git a/driverprotocol/protocol_parse_test.go b/driverprotocol/protocol_parse_test.go new file mode 100644 index 0000000..6dcae16 --- /dev/null +++ b/driverprotocol/protocol_parse_test.go @@ -0,0 +1,272 @@ +/* + * 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 driverprotocol + +import ( + "strings" + "testing" + + "github.com/goplus/mod/xgomod" +) + +func TestRejectMalformedArgv(t *testing.T) { + valid, err := Encode(testRequest()) + if err != nil { + t.Fatal(err) + } + tests := map[string][]string{ + "unknown": append(append([]string(nil), valid[:len(valid)-4]...), "--unknown=value", "--", ""), + "duplicate": append(append([]string(nil), valid[:2]...), append([]string{valid[2]}, valid[2:]...)...), + "partial pack": removeOption(valid, "--pack-index="), + "partial replace": removeOption(valid, "--replace-gomod="), + "missing work dir": removeOption(valid, "--graph-work-dir="), + "uppercase digest": replaceOptionValue(valid, "--declaration-sha256=", strings.Repeat("A", 64)), + "missing delimiter": func() []string { + copy := append([]string(nil), valid...) + for i, value := range copy { + if value == "--" { + return copy[:i] + } + } + return copy + }(), + } + for name, args := range tests { + t.Run(name, func(t *testing.T) { + if _, err := Parse(args); err == nil { + t.Fatalf("Parse(%#v) succeeded", args) + } + }) + } +} + +func TestSingularOptionSchema(t *testing.T) { + seen := make(map[string]struct{}, len(singularOptionSpecs)) + for _, spec := range singularOptionSpecs { + if _, duplicate := seen[spec.name]; duplicate { + t.Fatalf("duplicate singular option %q", spec.name) + } + seen[spec.name] = struct{}{} + } + + args, err := Encode(testRequest()) + if err != nil { + t.Fatal(err) + } + args = removeOption(args, "--project-dir=") + args = removeOption(args, "--project-file=") + if _, err := Parse(args); err == nil || !strings.Contains(err.Error(), "option --project-dir is required") { + t.Fatalf("Parse() error = %v, want first missing required option", err) + } +} + +func TestParseRejectsMalformedRequests(t *testing.T) { + runArgs, err := Encode(testRequest()) + if err != nil { + t.Fatal(err) + } + buildRequest := testRequest() + buildRequest.Action = ActionBuild + buildRequest.ApplicationArgs = nil + buildRequest.Project.Pack = nil + buildRequest.DriverOrigin = xgomod.ResolvedModule{ + Selected: xgomod.ModuleRef{ + Path: "example.test/framework", Version: "v1.2.3", + Dir: testPath("workspace", "framework"), GoMod: testPath("workspace", "framework", "go.mod"), + }, + } + buildRequest.Output = &BuildOutput{ + Staging: testPath("workspace", "out", ".game.tmp"), + Final: testPath("workspace", "out", "game"), + } + buildArgs, err := Encode(buildRequest) + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + args func() []string + want string + }{ + { + name: "empty argv", + args: func() []string { return nil }, + want: "requires preamble and action", + }, + { + name: "preamble only", + args: func() []string { return []string{PreambleV1} }, + want: "requires preamble and action", + }, + { + name: "unsupported preamble", + args: func() []string { + args := append([]string(nil), runArgs...) + args[0] = "other-driver" + return args + }, + want: "unsupported preamble", + }, + { + name: "unsupported action", + args: func() []string { return []string{PreambleV1, "test"} }, + want: "unsupported action", + }, + { + name: "build delimiter", + args: func() []string { + return append(append([]string(nil), buildArgs...), "--") + }, + want: "build does not accept", + }, + { + name: "run missing delimiter", + args: func() []string { + args := append([]string(nil), runArgs...) + for i, arg := range args { + if arg == "--" { + return args[:i] + } + } + return args + }, + want: "run requires --", + }, + { + name: "positional option", + args: func() []string { + args := append([]string(nil), runArgs...) + args[2] = "project-dir" + return args + }, + want: "unexpected positional argument", + }, + { + name: "malformed option", + args: func() []string { + args := append([]string(nil), runArgs...) + args[2] = "--project-dir" + return args + }, + want: "must use --name=value", + }, + { + name: "missing required option", + args: func() []string { return removeOption(runArgs, "--project-dir=") }, + want: "option --project-dir is required", + }, + { + name: "invalid origin main", + args: func() []string { return replaceOptionValue(runArgs, "--origin-main=", "maybe") }, + want: "invalid --origin-main", + }, + { + name: "missing selected source", + args: func() []string { + args := removeOption(buildArgs, "--selected-dir=") + return removeOption(args, "--selected-gomod=") + }, + want: "selected must provide both", + }, + { + name: "replacement with empty selected source", + args: func() []string { + return insertBeforeDelimiter(runArgs, "--selected-dir=") + }, + want: "with replacement forbids", + }, + { + name: "missing build output", + args: func() []string { return removeOption(buildArgs, "--output=") }, + want: "path --output may not be empty", + }, + { + name: "missing build final output", + args: func() []string { return removeOption(buildArgs, "--final-output=") }, + want: "path --final-output may not be empty", + }, + { + name: "run output", + args: func() []string { + return insertBeforeDelimiter(runArgs, "--output="+testPath("workspace", "out", "game")) + }, + want: "run request cannot contain output paths", + }, + { + name: "run final output", + args: func() []string { + return insertBeforeDelimiter(runArgs, "--final-output="+testPath("workspace", "out", "game")) + }, + want: "run request cannot contain output paths", + }, + { + name: "incomplete replacement", + args: func() []string { return removeOption(runArgs, "--replace-gomod=") }, + want: "complete group", + }, + { + name: "missing empty replacement version", + args: func() []string { return removeOption(runArgs, "--replace-version=") }, + want: "complete group", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := Parse(test.args()); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Parse() error = %v, want substring %q", err, test.want) + } + }) + } +} + +func removeOption(args []string, prefix string) []string { + result := make([]string, 0, len(args)) + for _, arg := range args { + if !strings.HasPrefix(arg, prefix) { + result = append(result, arg) + } + } + return result +} + +func insertBeforeDelimiter(args []string, value string) []string { + result := make([]string, 0, len(args)+1) + inserted := false + for _, arg := range args { + if !inserted && arg == "--" { + result = append(result, value) + inserted = true + } + result = append(result, arg) + } + if !inserted { + result = append(result, value) + } + return result +} + +func replaceOptionValue(args []string, prefix, value string) []string { + result := append([]string(nil), args...) + for i, arg := range result { + if strings.HasPrefix(arg, prefix) { + result[i] = prefix + value + return result + } + } + return result +} diff --git a/driverprotocol/protocol_roundtrip_test.go b/driverprotocol/protocol_roundtrip_test.go new file mode 100644 index 0000000..5c746c8 --- /dev/null +++ b/driverprotocol/protocol_roundtrip_test.go @@ -0,0 +1,167 @@ +/* + * 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 driverprotocol + +import ( + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/goplus/mod/xgomod" +) + +func TestRoundTripRunReplacement(t *testing.T) { + want := testRequest() + args, err := Encode(want) + if err != nil { + t.Fatal(err) + } + got, err := Parse(args) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("round trip = %#v, want %#v", got, want) + } + joined := strings.Join(args, "\n") + if strings.Contains(joined, "selected-dir") || !strings.Contains(joined, "--replace-dir="+testPath("workspace", "framework")) { + t.Fatalf("replacement identity was flattened:\n%s", joined) + } + if got.ApplicationArgs[0] != "" || got.ApplicationArgs[2] != "--" { + t.Fatalf("application argv changed: %#v", got.ApplicationArgs) + } +} + +func TestRoundTripBuildSelectedWithoutPack(t *testing.T) { + want := testRequest() + want.Action = ActionBuild + want.ApplicationArgs = nil + want.Project.Pack = nil + want.DriverOrigin = xgomod.ResolvedModule{ + Selected: xgomod.ModuleRef{ + Path: "example.test/framework", Version: "v1.2.3", + Dir: testPath("workspace", "framework"), GoMod: testPath("workspace", "framework", "go.mod"), + }, + } + want.Output = &BuildOutput{ + Staging: testPath("workspace", "out", ".game.tmp"), + Final: testPath("workspace", "out", "game"), + } + args, err := Encode(want) + if err != nil { + t.Fatal(err) + } + got, err := Parse(args) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("round trip = %#v, want %#v", got, want) + } + joined := strings.Join(args, "\n") + if strings.Contains(joined, "--pack-") || strings.Contains(joined, "--replace-") || strings.Contains(joined, "\n--\n") { + t.Fatalf("optional/action fields leaked:\n%s", joined) + } +} + +func TestRoundTripOriginVariantsAndWorkspace(t *testing.T) { + tests := map[string]xgomod.ResolvedModule{ + "main": { + Selected: xgomod.ModuleRef{ + Path: "example.test/framework", Dir: testPath("workspace", "framework"), GoMod: testPath("workspace", "framework", "go.mod"), + }, + Main: true, + }, + "version replacement": { + Selected: xgomod.ModuleRef{Path: "example.test/framework", Version: "v1.2.3"}, + Replace: &xgomod.ModuleRef{ + Path: "example.test/framework-fork", Version: "v1.4.0", + Dir: testPath("workspace", "framework-fork"), GoMod: testPath("workspace", "framework-fork", "go.mod"), + }, + }, + } + for name, origin := range tests { + t.Run(name, func(t *testing.T) { + want := testRequest() + want.DriverOrigin = origin + want.Declaration.Path = filepath.Join(origin.Effective().Dir, "gox.mod") + want.Graph.GoWork = testPath("workspace", "go.work") + want.Graph.Flags = append(want.Graph.Flags, "-overlay="+testPath("workspace", "overlay.json")) + args, err := Encode(want) + if err != nil { + t.Fatal(err) + } + got, err := Parse(args) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("round trip = %#v, want %#v", got, want) + } + }) + } +} + +func TestValidationIsStructural(t *testing.T) { + request := testRequest() + request.Project.Dir = testPath("does", "not", "exist", "game") + request.Project.File = testPath("does", "not", "exist", "game", "main.foo") + request.Project.ModuleRoot = testPath("does", "not", "exist") + request.Declaration.Path = testPath("does", "not", "exist", "framework", "gox.mod") + request.DriverOrigin.Replace.Path = testPath("does", "not", "exist", "framework") + request.DriverOrigin.Replace.Dir = testPath("does", "not", "exist", "framework") + request.DriverOrigin.Replace.GoMod = testPath("does", "not", "exist", "framework", "go.mod") + if err := request.Validate(); err != nil { + t.Fatalf("structural validation consulted ambient filesystem: %v", err) + } +} + +func TestPackDotIsDriverNeutral(t *testing.T) { + request := testRequest() + request.Project.Pack.Directory = "." + args, err := Encode(request) + if err != nil { + t.Fatalf("Encode() rejected modfile-valid pack directory dot: %v", err) + } + if _, err := Parse(args); err != nil { + t.Fatalf("Parse() rejected modfile-valid pack directory dot: %v", err) + } +} + +func TestEncodeDeterministicAndDetached(t *testing.T) { + request := testRequest() + first, err := Encode(request) + if err != nil { + t.Fatal(err) + } + second, err := Encode(request) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(first, second) { + t.Fatalf("Encode is not deterministic:\n%#v\n%#v", first, second) + } + parsed, err := Parse(first) + if err != nil { + t.Fatal(err) + } + first[len(first)-1] = "changed" + if parsed.ApplicationArgs[len(parsed.ApplicationArgs)-1] != "--" { + t.Fatalf("Parse retained argv backing storage: %#v", parsed.ApplicationArgs) + } +} diff --git a/driverprotocol/protocol_test.go b/driverprotocol/protocol_test.go new file mode 100644 index 0000000..bebcc0b --- /dev/null +++ b/driverprotocol/protocol_test.go @@ -0,0 +1,68 @@ +/* + * 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 driverprotocol + +import ( + "path/filepath" + "strings" + + "github.com/goplus/mod/xgomod" +) + +func testPath(parts ...string) string { + path, err := filepath.Abs(filepath.Join(append([]string{"driverprotocol-fixture"}, parts...)...)) + if err != nil { + panic(err) + } + return path +} + +func testRequest() Request { + return Request{ + Version: Version1, + Action: ActionRun, + Project: Project{ + Dir: testPath("workspace", "app", "game"), + File: testPath("workspace", "app", "game", "main.foo"), + ModuleRoot: testPath("workspace", "app"), + Extension: ".foo", + FullExtension: "*.foo", + Pack: &Pack{Directory: "payload", IndexFile: "index.data"}, + }, + DriverPackage: "example.test/framework/cmd/driver", + DriverOrigin: xgomod.ResolvedModule{ + Selected: xgomod.ModuleRef{Path: "example.test/framework", Version: "v1.2.3"}, + Replace: &xgomod.ModuleRef{ + Path: testPath("workspace", "framework"), + Dir: testPath("workspace", "framework"), + GoMod: testPath("workspace", "framework", "go.mod"), + }, + }, + Declaration: xgomod.FileIdentity{ + Path: testPath("workspace", "framework", "gox.mod"), + SHA256: strings.Repeat("a", 64), + }, + Graph: Graph{ + GoCommand: testPath("usr", "bin", "go"), + WorkDir: testPath("workspace", "app"), + GoWork: "off", + Flags: []string{"-mod=readonly", "-modfile=" + testPath("workspace", "app", "alt.mod")}, + }, + BuildFlags: []string{"-v=true", "-trimpath=true", "-buildvcs=false"}, + ApplicationArgs: []string{"", "a b", "--"}, + } +} diff --git a/driverprotocol/protocol_validation_test.go b/driverprotocol/protocol_validation_test.go new file mode 100644 index 0000000..5bc0464 --- /dev/null +++ b/driverprotocol/protocol_validation_test.go @@ -0,0 +1,380 @@ +/* + * 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 driverprotocol + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/goplus/mod/xgomod" +) + +func TestValidateRejectsStructuralRequests(t *testing.T) { + tests := []struct { + name string + mutate func(*Request) + want string + }{ + { + name: "unsupported action", + mutate: func(r *Request) { + r.Action = Action("test") + }, + want: "unsupported action", + }, + { + name: "empty project directory", + mutate: func(r *Request) { + r.Project.Dir = "" + }, + want: "path --project-dir may not be empty", + }, + { + name: "nul project file", + mutate: func(r *Request) { + r.Project.File += "\x00" + }, + want: "path --project-file may not be empty or contain NUL", + }, + { + name: "relative module root", + mutate: func(r *Request) { + r.Project.ModuleRoot = "workspace/app" + }, + want: "path --module-root must be absolute", + }, + { + name: "unclean declaration file", + mutate: func(r *Request) { + r.Declaration.Path = testPath("workspace", "framework") + string(filepath.Separator) + ".." + string(filepath.Separator) + "framework" + }, + want: "path --declaration-file must be clean", + }, + { + name: "unclean go command", + mutate: func(r *Request) { + r.Graph.GoCommand = testPath("usr", "bin") + string(filepath.Separator) + ".." + string(filepath.Separator) + "bin" + string(filepath.Separator) + "go" + }, + want: "path --go-command must be clean", + }, + { + name: "nul graph work directory", + mutate: func(r *Request) { + r.Graph.WorkDir += "\x00" + }, + want: "path --graph-work-dir may not be empty or contain NUL", + }, + { + name: "nested project file", + mutate: func(r *Request) { + r.Project.File = testPath("workspace", "app", "game", "nested", "main.foo") + }, + want: "project-file must be a top-level file", + }, + { + name: "project outside module root", + mutate: func(r *Request) { + r.Project.ModuleRoot = testPath("workspace", "other") + }, + want: "project-dir must be within module-root", + }, + { + name: "empty project extension", + mutate: func(r *Request) { + r.Project.Extension = "" + }, + want: "project extension may not be empty", + }, + { + name: "nul project extension", + mutate: func(r *Request) { + r.Project.Extension = ".foo\x00" + }, + want: "project extension may not be empty or contain NUL", + }, + { + name: "empty full extension", + mutate: func(r *Request) { + r.Project.FullExtension = "" + }, + want: "project full extension may not be empty", + }, + { + name: "nul full extension", + mutate: func(r *Request) { + r.Project.FullExtension = "*.foo\x00" + }, + want: "project full extension may not be empty or contain NUL", + }, + { + name: "empty pack directory", + mutate: func(r *Request) { + r.Project.Pack.Directory = "" + }, + want: "pack directory must be", + }, + { + name: "backslash pack directory", + mutate: func(r *Request) { + r.Project.Pack.Directory = `payload\\data` + }, + want: "pack directory must be", + }, + { + name: "absolute pack directory", + mutate: func(r *Request) { + r.Project.Pack.Directory = testPath("workspace", "app", "payload") + }, + want: "pack directory must be", + }, + { + name: "unclean pack directory", + mutate: func(r *Request) { + r.Project.Pack.Directory = "payload/../payload" + }, + want: "pack directory must be", + }, + { + name: "pack directory escapes project", + mutate: func(r *Request) { + r.Project.Pack.Directory = "../payload" + }, + want: "pack directory escapes", + }, + { + name: "invalid pack index", + mutate: func(r *Request) { + r.Project.Pack.IndexFile = "index/data" + }, + want: "pack index must be a plain file name", + }, + { + name: "invalid driver origin", + mutate: func(r *Request) { + r.DriverOrigin.Selected.Path = "bad path" + }, + want: "driver origin", + }, + { + name: "declaration outside driver metadata", + mutate: func(r *Request) { + r.Declaration.Path = testPath("workspace", "framework", "metadata.txt") + }, + want: "declaration-file must be driver metadata", + }, + { + name: "invalid driver package", + mutate: func(r *Request) { + r.DriverPackage = "bad package" + }, + want: "invalid driver package", + }, + { + name: "relative go work", + mutate: func(r *Request) { + r.Graph.GoWork = "workspace/go.work" + }, + want: "path --go-work must be absolute", + }, + { + name: "malformed graph flag", + mutate: func(r *Request) { + r.Graph.Flags = []string{"-mod"} + }, + want: "graph flag", + }, + { + name: "duplicate graph flag", + mutate: func(r *Request) { + r.Graph.Flags = []string{"-mod=mod", "-mod=readonly"} + }, + want: "graph flag -mod may not be repeated", + }, + { + name: "unsupported graph mode", + mutate: func(r *Request) { + r.Graph.Flags = []string{"-mod=bad"} + }, + want: "graph flag -mod has unsupported value", + }, + { + name: "unsupported graph flag", + mutate: func(r *Request) { + r.Graph.Flags = []string{"-tags=all"} + }, + want: "graph flag -tags is not supported", + }, + { + name: "malformed build flag", + mutate: func(r *Request) { + r.BuildFlags = []string{"-v"} + }, + want: "build flag", + }, + { + name: "unsupported build boolean", + mutate: func(r *Request) { + r.BuildFlags = []string{"-v=false"} + }, + want: "build flag -v has unsupported value", + }, + { + name: "unsupported build vcs value", + mutate: func(r *Request) { + r.BuildFlags = []string{"-buildvcs=true"} + }, + want: "build flag -buildvcs has unsupported value", + }, + { + name: "unsupported build flag", + mutate: func(r *Request) { + r.BuildFlags = []string{"-ldflags=-s"} + }, + want: "build flag -ldflags is not supported", + }, + { + name: "application argument nul", + mutate: func(r *Request) { + r.ApplicationArgs = []string{"ok\x00"} + }, + want: "application argument contains NUL", + }, + { + name: "short declaration digest", + mutate: func(r *Request) { + r.Declaration.SHA256 = strings.Repeat("a", 63) + }, + want: "must contain 64 hexadecimal characters", + }, + { + name: "non-hex declaration digest", + mutate: func(r *Request) { + r.Declaration.SHA256 = strings.Repeat("g", 64) + }, + want: "is not a SHA-256 digest", + }, + { + name: "build application arguments", + mutate: func(r *Request) { + r.Action = ActionBuild + r.Output = &BuildOutput{Staging: testPath("workspace", "out", ".game.tmp"), Final: testPath("workspace", "out", "game")} + }, + want: "build request cannot contain application arguments", + }, + { + name: "empty staging output", + mutate: func(r *Request) { + r.Action = ActionBuild + r.ApplicationArgs = nil + r.Output = &BuildOutput{Final: testPath("workspace", "out", "game")} + }, + want: "path --output may not be empty", + }, + { + name: "relative staging output", + mutate: func(r *Request) { + r.Action = ActionBuild + r.ApplicationArgs = nil + r.Output = &BuildOutput{Staging: "out/.game.tmp", Final: testPath("workspace", "out", "game")} + }, + want: "path --output must be absolute", + }, + { + name: "unclean final output", + mutate: func(r *Request) { + r.Action = ActionBuild + r.ApplicationArgs = nil + r.Output = &BuildOutput{Staging: testPath("workspace", "out", ".game.tmp"), Final: testPath("workspace", "out") + string(filepath.Separator) + ".." + string(filepath.Separator) + "out" + string(filepath.Separator) + "game"} + }, + want: "path --final-output must be clean", + }, + { + name: "same build outputs", + mutate: func(r *Request) { + r.Action = ActionBuild + r.ApplicationArgs = nil + output := testPath("workspace", "out", "game") + r.Output = &BuildOutput{Staging: output, Final: output} + }, + want: "output and final-output must be different", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := testRequest() + test.mutate(&request) + if err := request.Validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() error = %v, want substring %q", err, test.want) + } + }) + } +} + +func TestEncodeRejectsInvalidRequest(t *testing.T) { + request := testRequest() + request.Action = Action("test") + if _, err := Encode(request); err == nil || !strings.Contains(err.Error(), "unsupported action") { + t.Fatalf("Encode() error = %v", err) + } +} + +func TestRejectInvalidRequestShapes(t *testing.T) { + tests := map[string]func(*Request){ + "unsupported version": func(r *Request) { r.Version = "v2" }, + "build without output": func(r *Request) { + r.Action = ActionBuild + r.ApplicationArgs = nil + }, + "run with output": func(r *Request) { r.Output = &BuildOutput{Staging: testPath("tmp", "a"), Final: testPath("tmp", "b")} }, + "bad graph flag": func(r *Request) { r.Graph.Flags = []string{"-modfile=relative.mod"} }, + "relative graph work dir": func(r *Request) { r.Graph.WorkDir = "relative" }, + "bad build flag": func(r *Request) { r.BuildFlags = []string{"-ldflags=-s"} }, + "duplicate flag": func(r *Request) { r.BuildFlags = []string{"-v=true", "-v=true"} }, + "driver outside module": func(r *Request) { r.DriverPackage = "example.test/other/cmd/driver" }, + "flattened replacement": func(r *Request) { r.DriverOrigin.Selected.Dir = testPath("workspace", "framework") }, + "pack escapes": func(r *Request) { r.Project.Pack.Directory = "../payload" }, + "uppercase digest": func(r *Request) { r.Declaration.SHA256 = strings.Repeat("A", 64) }, + "declaration outside driver": func(r *Request) { + r.Declaration.Path = testPath("workspace", "other", "gox.mod") + }, + "main origin with version": func(r *Request) { + r.DriverOrigin = xgomod.ResolvedModule{ + Selected: xgomod.ModuleRef{ + Path: "example.test/framework", Version: "v1.2.3", + Dir: testPath("workspace", "framework"), GoMod: testPath("workspace", "framework", "go.mod"), + }, + Main: true, + } + }, + "local replace with module path": func(r *Request) { + r.DriverOrigin.Replace.Path = "example.test/framework-fork" + }, + "local replace identity mismatch": func(r *Request) { + r.DriverOrigin.Replace.Path = testPath("workspace", "other-framework") + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + request := testRequest() + mutate(&request) + if _, err := Encode(request); err == nil { + t.Fatal("Encode succeeded") + } + }) + } +} diff --git a/driverprotocol/request_validate.go b/driverprotocol/request_validate.go new file mode 100644 index 0000000..1ddcad8 --- /dev/null +++ b/driverprotocol/request_validate.go @@ -0,0 +1,126 @@ +/* + * 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 driverprotocol + +import ( + "fmt" + "path/filepath" + "strings" + + "golang.org/x/mod/module" +) + +// Validate checks the request without reading the filesystem. +func (r Request) Validate() error { + if r.Version != Version1 { + return fmt.Errorf("driverprotocol: unsupported version %q", r.Version) + } + if err := r.Action.Validate(); err != nil { + return err + } + for _, item := range []struct { + name string + value string + }{ + {"project-dir", r.Project.Dir}, + {"project-file", r.Project.File}, + {"module-root", r.Project.ModuleRoot}, + {"declaration-file", r.Declaration.Path}, + {"go-command", r.Graph.GoCommand}, + {"graph-work-dir", r.Graph.WorkDir}, + } { + if err := validateAbsolutePath(item.name, item.value); err != nil { + return err + } + } + if filepath.Dir(r.Project.File) != r.Project.Dir { + return fmt.Errorf("driverprotocol: project-file must be a top-level file in project-dir") + } + if !pathWithin(r.Project.ModuleRoot, r.Project.Dir) { + return fmt.Errorf("driverprotocol: project-dir must be within module-root") + } + if r.Project.Extension == "" || strings.IndexByte(r.Project.Extension, 0) >= 0 { + return fmt.Errorf("driverprotocol: project extension may not be empty or contain NUL") + } + if r.Project.FullExtension == "" || strings.IndexByte(r.Project.FullExtension, 0) >= 0 { + return fmt.Errorf("driverprotocol: project full extension may not be empty or contain NUL") + } + if r.Project.Pack != nil { + if err := validatePackDirectory(r.Project.Pack.Directory); err != nil { + return err + } + if err := validatePackIndex(r.Project.Pack.IndexFile); err != nil { + return err + } + } + if err := r.DriverOrigin.ValidateSyntax(); err != nil { + return fmt.Errorf("driverprotocol: driver origin: %w", err) + } + if err := validateSHA256("declaration-sha256", r.Declaration.SHA256); err != nil { + return err + } + effective := r.DriverOrigin.Effective() + declarationBase := filepath.Base(r.Declaration.Path) + if filepath.Dir(r.Declaration.Path) != effective.Dir || (declarationBase != "gox.mod" && declarationBase != "gop.mod") { + return fmt.Errorf("driverprotocol: declaration-file must be driver metadata (gox.mod or gop.mod) in %q", effective.Dir) + } + if err := module.CheckImportPath(r.DriverPackage); err != nil { + return fmt.Errorf("driverprotocol: invalid driver package %q: %w", r.DriverPackage, err) + } + if !moduleContainsPackage(r.DriverOrigin.Selected.Path, r.DriverPackage) { + return fmt.Errorf("driverprotocol: driver package %q is outside selected module %q", r.DriverPackage, r.DriverOrigin.Selected.Path) + } + if r.Graph.GoWork != "off" { + if err := validateAbsolutePath("go-work", r.Graph.GoWork); err != nil { + return err + } + } + if err := validateGraphFlags(r.Graph.Flags); err != nil { + return err + } + if err := validateBuildFlags(r.BuildFlags); err != nil { + return err + } + for _, arg := range r.ApplicationArgs { + if strings.IndexByte(arg, 0) >= 0 { + return fmt.Errorf("driverprotocol: application argument contains NUL") + } + } + switch r.Action { + case ActionRun: + if r.Output != nil { + return fmt.Errorf("driverprotocol: run request cannot contain output paths") + } + case ActionBuild: + if r.Output == nil { + return fmt.Errorf("driverprotocol: build request requires output paths") + } + if len(r.ApplicationArgs) != 0 { + return fmt.Errorf("driverprotocol: build request cannot contain application arguments") + } + if err := validateAbsolutePath("output", r.Output.Staging); err != nil { + return err + } + if err := validateAbsolutePath("final-output", r.Output.Final); err != nil { + return err + } + if r.Output.Staging == r.Output.Final { + return fmt.Errorf("driverprotocol: output and final-output must be different paths") + } + } + return nil +} diff --git a/driverprotocol/validation.go b/driverprotocol/validation.go new file mode 100644 index 0000000..362126e --- /dev/null +++ b/driverprotocol/validation.go @@ -0,0 +1,142 @@ +/* + * 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 driverprotocol + +import ( + "encoding/hex" + "fmt" + "path" + "path/filepath" + "strings" +) + +func validateAbsolutePath(name, value string) error { + if value == "" || strings.IndexByte(value, 0) >= 0 { + return fmt.Errorf("driverprotocol: path --%s may not be empty or contain NUL", name) + } + if !filepath.IsAbs(value) { + return fmt.Errorf("driverprotocol: path --%s must be absolute: %q", name, value) + } + if filepath.Clean(value) != value { + return fmt.Errorf("driverprotocol: path --%s must be clean: %q", name, value) + } + return nil +} + +func validatePackDirectory(value string) error { + if value == "" || strings.Contains(value, "\\") || strings.IndexByte(value, 0) >= 0 || path.IsAbs(value) || path.Clean(value) != value { + return fmt.Errorf("driverprotocol: pack directory must be a clean non-empty relative slash path: %q", value) + } + if value == ".." || strings.HasPrefix(value, "../") { + return fmt.Errorf("driverprotocol: pack directory escapes the project: %q", value) + } + return nil +} + +func validatePackIndex(value string) error { + if value == "" || value == "." || value == ".." || strings.ContainsAny(value, "/\\\x00") { + return fmt.Errorf("driverprotocol: pack index must be a plain file name: %q", value) + } + return nil +} + +func validateSHA256(name, value string) error { + if len(value) != 64 { + return fmt.Errorf("driverprotocol: --%s must contain 64 hexadecimal characters", name) + } + if _, err := hex.DecodeString(value); err != nil { + return fmt.Errorf("driverprotocol: --%s is not a SHA-256 digest: %w", name, err) + } + if value != strings.ToLower(value) { + return fmt.Errorf("driverprotocol: --%s must use lowercase hexadecimal", name) + } + return nil +} + +func validateGraphFlags(flags []string) error { + return validateFlags("graph", flags, func(name, value string) error { + switch name { + case "mod": + if value != "mod" && value != "readonly" && value != "vendor" { + return fmt.Errorf("driverprotocol: graph flag -mod has unsupported value %q", value) + } + case "modfile", "overlay": + if err := validateAbsolutePath("graph flag -"+name, value); err != nil { + return err + } + default: + return fmt.Errorf("driverprotocol: graph flag -%s is not supported", name) + } + return nil + }) +} + +func validateBuildFlags(flags []string) error { + return validateFlags("build", flags, func(name, value string) error { + switch name { + case "v", "x", "work", "trimpath": + if value != "true" { + return fmt.Errorf("driverprotocol: build flag -%s has unsupported value %q", name, value) + } + case "buildvcs": + if value != "false" { + return fmt.Errorf("driverprotocol: build flag -buildvcs has unsupported value %q", value) + } + default: + return fmt.Errorf("driverprotocol: build flag -%s is not supported", name) + } + return nil + }) +} + +func validateFlags(kind string, flags []string, validateValue func(name, value string) error) error { + seen := make(map[string]struct{}, len(flags)) + for _, flag := range flags { + name, value, ok := splitCanonicalFlag(flag) + if !ok { + return fmt.Errorf("driverprotocol: %s flag %q must use -name=value", kind, flag) + } + if _, duplicate := seen[name]; duplicate { + return fmt.Errorf("driverprotocol: %s flag -%s may not be repeated", kind, name) + } + seen[name] = struct{}{} + if err := validateValue(name, value); err != nil { + return err + } + } + return nil +} + +func splitCanonicalFlag(flag string) (name, value string, ok bool) { + if len(flag) < 4 || flag[0] != '-' || flag[1] == '-' || strings.IndexByte(flag, 0) >= 0 { + return "", "", false + } + name, value, ok = strings.Cut(flag[1:], "=") + return name, value, ok && name != "" && value != "" +} + +func pathWithin(root, target string) bool { + rel, err := filepath.Rel(root, target) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +func moduleContainsPackage(modulePath, packagePath string) bool { + return packagePath == modulePath || strings.HasPrefix(packagePath, modulePath+"/") +} diff --git a/modfile/rule.go b/modfile/rule.go index 1f032a5..fac2900 100644 --- a/modfile/rule.go +++ b/modfile/rule.go @@ -25,6 +25,7 @@ import ( "github.com/qiniu/x/errors" "golang.org/x/mod/modfile" + "golang.org/x/mod/module" ) type Compiler struct { @@ -82,6 +83,13 @@ type Pack struct { Syntax *Line } +// Driver declares a project's driver package and protocol version. +type Driver struct { + Protocol string + Package string + Syntax *Line +} + // A Project is the project statement. type Project struct { Ext string // can be "_[class].gox" or ".[class]", eg. "_yap.gox" or ".gmx" @@ -91,6 +99,7 @@ type Project struct { PkgPaths []string // package paths of classfile and optional inline-imported packages. Import []*Import // auto-imported packages Pack *Pack // pack directive (at most one per project) + Driver *Driver // project driver // AutoLambdas maps command => number of parameters before auto lambda. // See https://github.com/goplus/xgo/issues/2828. @@ -176,6 +185,10 @@ func parseToFile(file string, data []byte, fix VersionFixer, strict bool) (parse parsed.parseVerb(&errs, x.Token[0], x, x.Token[1:], strict) case *LineBlock: verb := x.Token[0] + if verb == "driver" && len(x.Line) == 0 { + parsed.parseVerb(&errs, verb, &Line{Comments: x.Comments, Start: x.Start, End: x.RParen.Pos, Token: x.Token, InBlock: true}, nil, strict) + continue + } for _, line := range x.Line { parsed.parseVerb(&errs, verb, line, line.Token, strict) } @@ -390,6 +403,43 @@ usage: class [-embed -prefix=Prefix] *.workExt WorkClass [WorkPrototype]`, sw) return } proj.Pack = &Pack{Directory: dir, IndexFile: indexFile, Syntax: line} + case "driver": + if line.InBlock { + errorf("driver directive must not be a block") + return + } + proj := f.proj() + if proj == nil { + errorf("driver must declare after a project definition") + return + } + if proj.Driver != nil { + errorf("duplicate driver directive in the same project") + return + } + if len(args) != 2 { + errorf("usage: driver ") + return + } + protocol, err := parseString(&args[0]) + if err != nil { + wrapError(err) + return + } + if !driverProtocolRE.MatchString(protocol) { + errorf("driver protocol must match v[1-9][0-9]*, got %q", protocol) + return + } + pkgPath, err := parseString(&args[1]) + if err != nil { + wrapError(err) + return + } + if err := module.CheckImportPath(pkgPath); err != nil { + errorf("driver package %q is not a valid import path: %v", pkgPath, err) + return + } + proj.Driver = &Driver{Protocol: protocol, Package: pkgPath, Syntax: line} case "autolambda": proj := f.proj() if proj == nil { @@ -485,8 +535,9 @@ func AutoQuote(s string) string { } var ( - typeRE = regexp.MustCompile(`\*?[A-Z]\w*`) - idenRE = regexp.MustCompile(`\w+`) + typeRE = regexp.MustCompile(`\*?[A-Z]\w*`) + idenRE = regexp.MustCompile(`\w+`) + driverProtocolRE = regexp.MustCompile(`^v[1-9][0-9]*$`) ) // TODO(xsw): to be optimized diff --git a/modfile/rule_test.go b/modfile/rule_test.go index 770d90a..256e540 100644 --- a/modfile/rule_test.go +++ b/modfile/rule_test.go @@ -16,6 +16,7 @@ package modfile import ( + "strings" "syscall" "testing" ) @@ -157,6 +158,78 @@ func TestParsePack(t *testing.T) { } } +func TestParseDriver(t *testing.T) { + const src = ` +xgo 1.6 + +project main.foo Game example.com/framework math +driver v1 example.com/framework/cmd/driver // driver +` + f, err := ParseLax("gox.mod", []byte(src), nil) + if err != nil { + t.Fatal("ParseLax failed:", err) + } + proj := f.proj() + if proj == nil || proj.Driver == nil { + t.Fatal("expected driver") + } + if proj.Driver.Protocol != "v1" || proj.Driver.Package != "example.com/framework/cmd/driver" { + t.Fatalf("driver = %#v", proj.Driver) + } + formatted := Format(f.Syntax) + f2, err := ParseLax("gox.mod", formatted, nil) + if err != nil { + t.Fatal("round-trip ParseLax failed:", err) + } + if got := f2.proj().Driver; got == nil || got.Protocol != "v1" || got.Package != proj.Driver.Package { + t.Fatalf("round-trip driver = %#v", got) + } +} + +func TestParseDriverErrors(t *testing.T) { + tests := []struct { + name string + want string + src string + }{ + {"before project", "driver must declare after a project definition", "driver v1 example.com/driver"}, + {"wrong arity", "usage: driver ", "project example.com/app\ndriver v1"}, + {"invalid protocol", "driver protocol must match v[1-9][0-9]*", "project example.com/app\ndriver 1 example.com/driver"}, + {"zero protocol", "driver protocol must match v[1-9][0-9]*", "project example.com/app\ndriver v0 example.com/driver"}, + {"malformed protocol quote", "invalid syntax", "project example.com/app\ndriver \"bad\\q\" example.com/driver"}, + {"invalid package", "driver package", "project example.com/app\ndriver v1 ../driver"}, + {"malformed package quote", "invalid syntax", "project example.com/app\ndriver v1 \"bad\\q\""}, + {"duplicate", "duplicate driver directive in the same project", "project example.com/app\ndriver v1 example.com/driver\ndriver v1 example.com/driver"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, parse := range []func(string, []byte) (*File, error){ + func(name string, data []byte) (*File, error) { return Parse(name, data, nil) }, + func(name string, data []byte) (*File, error) { return ParseLax(name, data, nil) }, + } { + _, err := parse("gox.mod", []byte(tt.src)) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want substring %q", err, tt.want) + } + } + }) + } +} + +func TestParseDriverIsNotABlockDirective(t *testing.T) { + _, err := ParseLax("gox.mod", []byte(`project example.com/app +driver ( +v1 example.com/driver +)`), nil) + if err == nil || !strings.Contains(err.Error(), "driver directive must not be a block") { + t.Fatalf("error = %v", err) + } + _, err = ParseLax("gox.mod", []byte("project example.com/app\ndriver (\n)\n"), nil) + if err == nil || !strings.Contains(err.Error(), "driver directive must not be a block") { + t.Fatalf("empty block error = %v", err) + } +} + const goxmodMultiProject = ` xgo 1.6 diff --git a/modload/module.go b/modload/module.go index ccb7b63..a1c12df 100644 --- a/modload/module.go +++ b/modload/module.go @@ -17,10 +17,14 @@ package modload import ( + "crypto/sha256" + "encoding/hex" "fmt" "os" "path/filepath" "strings" + "unicode" + "unicode/utf8" "github.com/goplus/mod" "github.com/goplus/mod/env" @@ -40,7 +44,25 @@ var ( type Module struct { *gomodfile.File - Opt *modfile.File + Opt *modfile.File + goModIdentity FileIdentity + goxModIdentity FileIdentity +} + +// FileIdentity binds a module file to the SHA-256 of bytes read; zero means none. +type FileIdentity struct { + Path string + SHA256 string +} + +// GoModIdentity returns the go.mod snapshot used to parse File. +func (p Module) GoModIdentity() FileIdentity { + return p.goModIdentity +} + +// GoxModIdentity returns the gox.mod or gop.mod snapshot used to parse Opt. +func (p Module) GoxModIdentity() FileIdentity { + return p.goxModIdentity } // HasModfile returns if this module exists or not. @@ -141,7 +163,7 @@ func Create(dir string, modPath, goVer, xgoVer string) (p Module, err error) { } mod := newGoMod(gomod, modPath, goVer) opt := newGoxMod(goxmod, xgoVer) - return Module{mod, opt}, nil + return Module{File: mod, Opt: opt}, nil } func newGoMod(gomod, modPath, goVer string) *gomodfile.File { @@ -193,6 +215,7 @@ func LoadFromEx(gomod, goxmod string, readFile func(string) ([]byte, error)) (p err = errors.NewWith(err, `readFile(gomod)`, -2, "readFile", gomod) return } + goModIdentity := fileIdentity(gomod, data) var fixed bool fix := fixVersion(&fixed) @@ -213,6 +236,7 @@ func LoadFromEx(gomod, goxmod string, readFile func(string) ([]byte, error)) (p } var opt *modfile.File + var goxModIdentity FileIdentity if goxmod != "" { data, err = readFile(goxmod) if err != nil { @@ -223,6 +247,7 @@ func LoadFromEx(gomod, goxmod string, readFile func(string) ([]byte, error)) (p } } if err == nil { + goxModIdentity = fileIdentity(goxmod, data) opt, err = modfile.ParseLax(goxmod, data, fix) if err != nil { err = errors.NewWith(err, `modfile.Parse(goxmod, data, fix)`, -2, "modfile.Parse", goxmod, data, fix) @@ -237,7 +262,12 @@ func LoadFromEx(gomod, goxmod string, readFile func(string) ([]byte, error)) (p if cl := getGoCompiler(f); cl != nil { opt.Compiler = cl } - return Module{f, opt}, nil + return Module{File: f, Opt: opt, goModIdentity: goModIdentity, goxModIdentity: goxModIdentity}, nil +} + +func fileIdentity(path string, data []byte) FileIdentity { + sum := sha256.Sum256(data) + return FileIdentity{Path: path, SHA256: hex.EncodeToString(sum[:])} } // AddCompiler adds a custom Go compiler to this module. @@ -297,11 +327,31 @@ func addClass(opt *modfile.File, r *gomodfile.Require) { func isClass(r *gomodfile.Require) bool { if line := r.Syntax; line != nil { - for _, c := range line.Suffix { - text := strings.TrimLeft(c.Token[2:], " \t") - if strings.HasPrefix(text, "xgo:class") || strings.HasPrefix(text, "gop:class") { + return HasClassMarker(line.Suffix) + } + return false +} + +// HasClassMarker reports a token-boundary xgo:class or gop:class marker. +func HasClassMarker(comments []gomodfile.Comment) bool { + for _, comment := range comments { + if !strings.HasPrefix(comment.Token, "//") { + continue + } + text := strings.TrimLeftFunc(comment.Token[2:], unicode.IsSpace) + for _, marker := range [...]string{"xgo:class", "gop:class"} { + if text == marker { return true } + if strings.HasPrefix(text, marker) { + rest := text[len(marker):] + if rest != "" { + first, _ := utf8.DecodeRuneInString(rest) + if unicode.IsSpace(first) { + return true + } + } + } } } return false diff --git a/modload/module_test.go b/modload/module_test.go index 64d9579..12236cd 100644 --- a/modload/module_test.go +++ b/modload/module_test.go @@ -20,6 +20,8 @@ import ( "encoding/json" "log" "os" + "path/filepath" + "reflect" "runtime" "testing" @@ -65,6 +67,93 @@ func TestEmpty(t *testing.T) { } } +func TestHasClassMarker(t *testing.T) { + tests := []struct { + token string + want bool + }{ + {"//xgo:class", true}, + {"// xgo:class", true}, + {"//xgo:class payload", true}, + {"//gop:class\tpayload", true}, + {"// gop:class ", true}, + {"//xgo:classroom", false}, + {"//gop:classes", false}, + {"//xgo:class-payload", false}, + {"//prefix xgo:class", false}, + {"xgo:class", false}, + } + for _, test := range tests { + comments := []gomodfile.Comment{{Token: test.token}} + if got := HasClassMarker(comments); got != test.want { + t.Errorf("HasClassMarker(%q) = %v, want %v", test.token, got, test.want) + } + } +} + +func TestLoadClassMarkerOrderAndBoundary(t *testing.T) { + const goMod = `module example.com/app + +go 1.25 + +require ( + example.com/second v1.0.0 //gop:class payload + example.com/classroom v1.0.0 //xgo:classroom + example.com/first v1.0.0 // xgo:class +) +` + mod, err := LoadFromEx("memory/go.mod", "", func(string) ([]byte, error) { + return []byte(goMod), nil + }) + if err != nil { + t.Fatal(err) + } + want := []string{"example.com/second", "example.com/first"} + if !reflect.DeepEqual(mod.Opt.ClassMods, want) { + t.Fatalf("ClassMods = %#v, want %#v", mod.Opt.ClassMods, want) + } +} + +func TestModuleFileIdentitiesAreReadOnlySnapshots(t *testing.T) { + dir := t.TempDir() + goMod := filepath.Join(dir, "go.mod") + goxMod := filepath.Join(dir, "gox.mod") + if err := os.WriteFile(goMod, []byte("module example.com/app\n\ngo 1.25\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(goxMod, []byte("xgo 1.9\n"), 0644); err != nil { + t.Fatal(err) + } + mod, err := LoadFrom(goMod, goxMod) + if err != nil { + t.Fatal(err) + } + goIdentity := mod.GoModIdentity() + goxIdentity := mod.GoxModIdentity() + if goIdentity.Path != goMod || len(goIdentity.SHA256) != 64 { + t.Fatalf("go.mod identity = %#v", goIdentity) + } + if goxIdentity.Path != goxMod || len(goxIdentity.SHA256) != 64 { + t.Fatalf("gox.mod identity = %#v", goxIdentity) + } + goIdentity.Path = "tampered" + goIdentity.SHA256 = "tampered" + if got := mod.GoModIdentity(); got.Path != goMod || len(got.SHA256) != 64 { + t.Fatalf("stored go.mod identity was mutable: %#v", got) + } + + inMemory, err := Create(filepath.Join(dir, "new"), "example.com/new", "1.25", "1.9") + if err != nil { + t.Fatal(err) + } + if got := inMemory.GoModIdentity(); got != (FileIdentity{}) { + t.Fatalf("in-memory go.mod identity = %#v", got) + } + if got := inMemory.GoxModIdentity(); got != (FileIdentity{}) { + t.Fatalf("in-memory gox.mod identity = %#v", got) + } +} + func TestLoad(t *testing.T) { if _, e := Load("/path/not-found"); errors.Err(e) != mod.ErrNotFound { t.Fatal("TestLoad:", e) diff --git a/xgomod/classfile.go b/xgomod/classfile.go index 4d6f0f0..806e63e 100644 --- a/xgomod/classfile.go +++ b/xgomod/classfile.go @@ -96,6 +96,7 @@ func (p *Module) ImportClasses(importClass ...func(c *Project)) (err error) { impcls = importClass[0] } p.projs = make(map[string]*Project) + p.infos = make(map[string]*ProjectInfo) p.importClass(TestProject, impcls) p.importClass(GshProject, impcls) opt := p.Opt @@ -146,13 +147,12 @@ func (p *Module) importClassFrom(modVer module.Version, impcls func(c *Project)) } func (p *Module) importClass(c *Project, impcls func(c *Project)) { - p.projs[c.Ext] = c - for _, w := range c.Works { - p.projs[w.Ext] = c + info := &ProjectInfo{Project: c} + for _, ext := range projectExts(c) { + p.projs[ext] = c + p.infos[ext] = info } if impcls != nil { impcls(c) } } - -// ----------------------------------------------------------------------------- diff --git a/xgomod/classfile_provenance.go b/xgomod/classfile_provenance.go new file mode 100644 index 0000000..a2307cd --- /dev/null +++ b/xgomod/classfile_provenance.go @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2021 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xgomod + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// LookupClassInfo returns class metadata and provenance; built-ins have none. +func (p *Module) LookupClassInfo(ext string) (*ProjectInfo, bool) { + if info, ok := p.infos[ext]; ok { + return info, true + } + if project, ok := p.projs[ext]; ok { + // Preserve legacy lookups without fabricating provenance. + return &ProjectInfo{Project: project}, true + } + return nil, false +} + +// ImportClassesResolved imports class metadata from a validated graph. +func (p *Module) ImportClassesResolved(graph ResolvedClassGraph) error { + if p == nil || p.File == nil || p.Opt == nil { + return fmt.Errorf("receiver has no target module snapshot") + } + if err := graph.validate(); err != nil { + return err + } + receiverIdentity := p.GoModIdentity() + if receiverIdentity.Path == "" || receiverIdentity.SHA256 == "" { + return fmt.Errorf("receiver has no target modfile snapshot") + } + if p.Path() != graph.Target.Selected.Path && p.Path() != graph.Target.Effective().Path { + return fmt.Errorf("receiver module %q does not match graph target %q", p.Path(), graph.Target.Selected.Path) + } + receiverModfile, err := canonicalPath(receiverIdentity.Path, false) + if err != nil { + return fmt.Errorf("receiver target modfile: %w", err) + } + graphModfile, err := canonicalPath(graph.TargetModFile.Path, false) + if err != nil { + return fmt.Errorf("graph target modfile: %w", err) + } + if receiverModfile != graphModfile { + return fmt.Errorf("receiver and graph target modfile snapshots differ") + } + if !strings.EqualFold(receiverIdentity.SHA256, graph.TargetModFile.SHA256) { + return fmt.Errorf("receiver and graph target modfile contents differ") + } + targetRoot, err := canonicalPath(graph.Target.Effective().Dir, true) + if err != nil { + return fmt.Errorf("graph target source: %w", err) + } + declarationPath, declarationDigest, err := receiverGoxSnapshot(p, targetRoot) + if err != nil { + return err + } + declaration := FileIdentity{Path: declarationPath, SHA256: declarationDigest} + + projects := make(map[string]*Project) + infos := make(map[string]*ProjectInfo) + register := func(info *ProjectInfo) error { + return registerProject(projects, infos, info) + } + // Built-ins have no module provenance. + for _, builtin := range []*Project{TestProject, GshProject} { + if err := register(&ProjectInfo{Project: builtin}); err != nil { + return err + } + } + + origin := graph.Target + required := "" + if p.Opt.XGo != nil { + required = p.Opt.XGo.Version + } + for _, project := range p.Projects() { + if err := register(&ProjectInfo{Project: project, Origin: cloneResolvedModule(origin), Declaration: declaration, RequiredXGo: required}); err != nil { + return err + } + } + + for _, record := range graph.ClassModules { + classMod := record.Selected.Path + moduleProjects, err := importResolvedModule(record) + if err != nil { + return fmt.Errorf("import class module %q: %w", classMod, err) + } + for _, info := range moduleProjects { + if err := register(info); err != nil { + return err + } + } + } + p.projs = projects + p.infos = infos + return nil +} + +func receiverGoxSnapshot(p *Module, targetRoot string) (path, digest string, err error) { + identity := p.GoxModIdentity() + if identity.Path == "" && identity.SHA256 == "" { + for _, candidate := range []string{filepath.Join(targetRoot, "gox.mod"), filepath.Join(targetRoot, "gop.mod")} { + if _, statErr := os.Stat(candidate); statErr == nil { + return "", "", fmt.Errorf("receiver target gox.mod appeared without load snapshot") + } else if !os.IsNotExist(statErr) { + return "", "", fmt.Errorf("check receiver target gox.mod: %w", statErr) + } + } + if len(p.Projects()) != 0 { + return "", "", fmt.Errorf("receiver has projects without a target gox.mod snapshot") + } + return "", "", nil + } + if identity.Path == "" || identity.SHA256 == "" { + return "", "", fmt.Errorf("receiver target gox.mod snapshot is incomplete") + } + path, err = canonicalPath(identity.Path, false) + if err != nil { + return "", "", fmt.Errorf("receiver target gox.mod: %w", err) + } + if !pathWithin(targetRoot, path) { + return "", "", fmt.Errorf("receiver target gox.mod is outside graph target source") + } + _, digest, err = readFileSHA256(path) + if err != nil { + return "", "", fmt.Errorf("read receiver target gox.mod: %w", err) + } + if !strings.EqualFold(digest, identity.SHA256) { + return "", "", fmt.Errorf("receiver target gox.mod contents changed after load") + } + return path, digest, nil +} + +func registerProject(projects map[string]*Project, infos map[string]*ProjectInfo, info *ProjectInfo) error { + if info == nil || info.Project == nil { + return fmt.Errorf("class metadata contains a nil project") + } + if info.Project.Driver != nil && info.Origin == nil { + return fmt.Errorf("driver-backed project %q has no module provenance", info.Project.Ext) + } + for _, ext := range projectExts(info.Project) { + if old, ok := infos[ext]; ok && old != info { + if old.Project == info.Project { + continue + } + if old.Project.Driver != nil || info.Project.Driver != nil { + return fmt.Errorf("driver-backed class extension collision for %q between %q and %q", ext, old.Project.Class, info.Project.Class) + } + } + projects[ext] = info.Project + infos[ext] = info + } + return nil +} + +func projectExts(project *Project) []string { + exts := make([]string, 0, len(project.Works)+1) + exts = append(exts, project.Ext) + for _, work := range project.Works { + exts = append(exts, work.Ext) + } + return exts +} diff --git a/xgomod/module.go b/xgomod/module.go index 5f57704..f8b58fe 100644 --- a/xgomod/module.go +++ b/xgomod/module.go @@ -48,6 +48,7 @@ type DepMod struct { type Module struct { modload.Module projs map[string]*Project // ext -> project + infos map[string]*ProjectInfo deps []DepMod } diff --git a/xgomod/path.go b/xgomod/path.go new file mode 100644 index 0000000..10fbcf1 --- /dev/null +++ b/xgomod/path.go @@ -0,0 +1,33 @@ +/* + * 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 xgomod + +import ( + "path/filepath" + "strings" +) + +// pathWithin reports whether target is root itself or a descendant of root. +// filepath.Rel can return an absolute path on some platforms (for example, +// when the paths are on different volumes); that is never a descendant. +func pathWithin(root, target string) bool { + rel, err := filepath.Rel(root, target) + if err != nil || filepath.IsAbs(rel) { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} diff --git a/xgomod/path_test.go b/xgomod/path_test.go new file mode 100644 index 0000000..8bbe133 --- /dev/null +++ b/xgomod/path_test.go @@ -0,0 +1,43 @@ +/* + * 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 xgomod + +import ( + "path/filepath" + "testing" +) + +func TestPathWithin(t *testing.T) { + root := t.TempDir() + tests := []struct { + name string + target string + want bool + }{ + {"root", root, true}, + {"descendant", filepath.Join(root, "nested", "go.mod"), true}, + {"parent", filepath.Dir(root), false}, + {"sibling with common prefix", root + "-other", false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := pathWithin(root, test.target); got != test.want { + t.Fatalf("pathWithin(%q, %q) = %v, want %v", root, test.target, got, test.want) + } + }) + } +} diff --git a/xgomod/resolved.go b/xgomod/resolved.go new file mode 100644 index 0000000..ffafe4b --- /dev/null +++ b/xgomod/resolved.go @@ -0,0 +1,90 @@ +/* + * 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 xgomod + +import ( + "github.com/goplus/mod/modfile" + "github.com/goplus/mod/modload" +) + +// ModuleRef identifies a logical selection or its effective source. +type ModuleRef struct { + Path string + Version string + Dir string + GoMod string +} + +// ResolvedModule separates selection from replacement source paths. +type ResolvedModule struct { + Selected ModuleRef + Replace *ModuleRef + Main bool +} + +// Effective returns the source used for files and metadata. +func (m ResolvedModule) Effective() ModuleRef { + if m.Replace != nil { + return *m.Replace + } + return m.Selected +} + +// Equal reports whether two resolved module identities are identical. +func (m ResolvedModule) Equal(other ResolvedModule) bool { + if m.Main != other.Main || m.Selected != other.Selected { + return false + } + if m.Replace == nil || other.Replace == nil { + return m.Replace == nil && other.Replace == nil + } + return *m.Replace == *other.Replace +} + +// IsLocal reports whether the module uses filesystem source. +func (m ResolvedModule) IsLocal() bool { + return m.Main || m.Replace != nil && m.Replace.Version == "" +} + +// Validate checks the resolved module identity and its effective source. +func (m ResolvedModule) Validate() error { + return validateResolvedModule(m) +} + +// ValidateSyntax checks identity and path spelling without filesystem access. +func (m ResolvedModule) ValidateSyntax() error { + return validateResolvedModuleSyntax(m) +} + +// ResolvedClassGraph is XGo's resolved graph snapshot; it is not rediscovered. +type ResolvedClassGraph struct { + Target ResolvedModule + // ClassModules follows class-marked require order; order controls registration precedence. + ClassModules []ResolvedModule + TargetModFile FileIdentity +} + +// FileIdentity binds metadata to the exact bytes parsed by the caller. +type FileIdentity = modload.FileIdentity + +// ProjectInfo pairs class metadata with its origin; built-ins omit provenance. +type ProjectInfo struct { + Project *modfile.Project + Origin *ResolvedModule + Declaration FileIdentity + RequiredXGo string +} diff --git a/xgomod/resolved_graph.go b/xgomod/resolved_graph.go new file mode 100644 index 0000000..46e2586 --- /dev/null +++ b/xgomod/resolved_graph.go @@ -0,0 +1,75 @@ +/* + * 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 xgomod + +import ( + "fmt" + + "github.com/goplus/mod/modload" + gomodfile "golang.org/x/mod/modfile" +) + +func (g ResolvedClassGraph) validate() error { + if err := validateResolvedModule(g.Target); err != nil { + return fmt.Errorf("target: %w", err) + } + targetModData, err := validateFileIdentity(g.TargetModFile) + if err != nil { + return err + } + markerPaths, err := classModulePaths(g.TargetModFile.Path, targetModData) + if err != nil { + return fmt.Errorf("parse target modfile: %w", err) + } + seenMarkers := make(map[string]struct{}, len(markerPaths)) + for _, path := range markerPaths { + if path == g.Target.Selected.Path { + return fmt.Errorf("target module %q is also marked as a class module", path) + } + if _, ok := seenMarkers[path]; ok { + return fmt.Errorf("duplicate class module marker %q", path) + } + seenMarkers[path] = struct{}{} + } + if len(g.ClassModules) != len(markerPaths) { + return fmt.Errorf("resolved class module count %d does not match target modfile marker count %d", len(g.ClassModules), len(markerPaths)) + } + for i, mod := range g.ClassModules { + path := mod.Selected.Path + if path != markerPaths[i] { + return fmt.Errorf("class module %d has logical path %q, want marker %q", i, path, markerPaths[i]) + } + if err := validateResolvedModule(mod); err != nil { + return fmt.Errorf("class module %q: %w", path, err) + } + } + return nil +} + +func classModulePaths(path string, data []byte) ([]string, error) { + f, err := gomodfile.Parse(path, data, nil) + if err != nil { + return nil, err + } + paths := make([]string, 0) + for _, require := range f.Require { + if require.Syntax != nil && modload.HasClassMarker(require.Syntax.Suffix) { + paths = append(paths, require.Mod.Path) + } + } + return paths, nil +} diff --git a/xgomod/resolved_graph_test.go b/xgomod/resolved_graph_test.go new file mode 100644 index 0000000..30a5ed8 --- /dev/null +++ b/xgomod/resolved_graph_test.go @@ -0,0 +1,173 @@ +/* + * 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 xgomod + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/goplus/mod/modfile" + "github.com/goplus/mod/modload" + "golang.org/x/mod/module" +) + +func TestResolvedClassGraphRejectsInvalidClassModuleLists(t *testing.T) { + root := t.TempDir() + targetGoMod := writeModule(t, root, "example.com/app", "") + if err := os.WriteFile(targetGoMod, []byte(`module example.com/app + +go 1.25 + +require ( + example.com/first v1.0.0 //xgo:class + example.com/second v1.0.0 //gop:class +) +`), 0644); err != nil { + t.Fatal(err) + } + target := graphModule("example.com/app", "", root, targetGoMod, true) + moduleRecord := func(path string) ResolvedModule { + dir := filepath.Join(root, filepath.Base(path)) + goMod := writeModule(t, dir, path, "") + return graphModule(path, "v1.0.0", dir, goMod, false) + } + first := moduleRecord("example.com/first") + second := moduleRecord("example.com/second") + third := moduleRecord("example.com/third") + identity := graphIdentity(t, targetGoMod) + tests := []struct { + name string + modules []ResolvedModule + match string + }{ + {"wrong order", []ResolvedModule{second, first}, "want marker"}, + {"duplicate", []ResolvedModule{first, first}, "want marker"}, + {"target repeated", []ResolvedModule{target, second}, "want marker"}, + {"missing", []ResolvedModule{first}, "module count"}, + {"extra", []ResolvedModule{first, second, third}, "module count"}, + {"wrong logical path", []ResolvedModule{first, third}, "want marker"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + graph := ResolvedClassGraph{Target: target, ClassModules: test.modules, TargetModFile: identity} + if err := graph.validate(); err == nil || !strings.Contains(err.Error(), test.match) { + t.Fatalf("error = %v, want substring %q", err, test.match) + } + }) + } +} + +func TestResolvedClassGraphRejectsDuplicateAndTargetMarkers(t *testing.T) { + for _, test := range []struct { + name string + body string + match string + }{ + {"duplicate", "require example.com/dup v1.0.0 //xgo:class\nrequire example.com/dup v1.0.1 //gop:class\n", "duplicate class module marker"}, + {"target", "require example.com/app v1.0.0 //xgo:class\n", "also marked as a class module"}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + goMod := writeModule(t, root, "example.com/app", "") + if err := os.WriteFile(goMod, []byte("module example.com/app\n\ngo 1.25\n\n"+test.body), 0644); err != nil { + t.Fatal(err) + } + target := graphModule("example.com/app", "", root, goMod, true) + graph := ResolvedClassGraph{Target: target, TargetModFile: graphIdentity(t, goMod)} + if err := graph.validate(); err == nil || !strings.Contains(err.Error(), test.match) { + t.Fatalf("error = %v, want substring %q", err, test.match) + } + }) + } +} + +func TestResolvedGraphRejectsReplacementPathLeak(t *testing.T) { + root := t.TempDir() + goMod := writeModule(t, root, "example.com/app", "xgo 1.9\n") + bad := ResolvedModule{Selected: ModuleRef{Path: "example.com/app", Version: "v1.0.0"}} + bad.Selected.Dir = filepath.Join(root, "selected") + bad.Selected.GoMod = filepath.Join(root, "selected", "go.mod") + bad.Replace = &ModuleRef{Path: root, Dir: root, GoMod: goMod} + graph := ResolvedClassGraph{Target: bad, TargetModFile: graphIdentity(t, goMod)} + if err := graph.validate(); err == nil || !strings.Contains(err.Error(), "selected Dir/GoMod must be empty") { + t.Fatalf("error = %v", err) + } +} + +func TestResolvedGraphRejectsClassModWithoutRecord(t *testing.T) { + root := t.TempDir() + goMod := writeModule(t, root, "example.com/app", "xgo 1.9\n") + if err := os.WriteFile(goMod, []byte("module example.com/app\n\ngo 1.25\n\nrequire example.com/missing v1.0.0 //xgo:class\n"), 0644); err != nil { + t.Fatal(err) + } + target := graphModule("example.com/app", "", root, goMod, true) + graph := ResolvedClassGraph{Target: target, TargetModFile: graphIdentity(t, goMod)} + if err := graph.validate(); err == nil || !strings.Contains(err.Error(), "module count") { + t.Fatalf("error = %v", err) + } +} + +func TestLookupClassInfoLegacyFallbackAndRegistrationSafety(t *testing.T) { + legacy := &Project{Ext: ".legacy", Class: "Legacy"} + m := &Module{projs: map[string]*Project{legacy.Ext: legacy}} + info, ok := m.LookupClassInfo(legacy.Ext) + if !ok || info.Project != legacy || info.Origin != nil || info.RequiredXGo != "" { + t.Fatalf("legacy info = %#v, ok=%v", info, ok) + } + if _, ok := m.LookupClassInfo(".missing"); ok { + t.Fatal("missing class unexpectedly resolved") + } + + if err := registerProject(nil, nil, nil); err == nil || !strings.Contains(err.Error(), "nil project") { + t.Fatalf("nil project error = %v", err) + } + driverBackedProject := &Project{ + Ext: ".driver", + Driver: &modfile.Driver{Protocol: "v1", Package: "example.com/driver"}, + } + if err := registerProject(map[string]*Project{}, map[string]*ProjectInfo{}, &ProjectInfo{Project: driverBackedProject}); err == nil || !strings.Contains(err.Error(), "driver-backed project") { + t.Fatalf("orphan driver-backed project error = %v", err) + } + + same := &Project{Ext: ".same", Class: "Same"} + projects := map[string]*Project{same.Ext: same} + infos := map[string]*ProjectInfo{same.Ext: {Project: same}} + if err := registerProject(projects, infos, &ProjectInfo{Project: same}); err != nil { + t.Fatalf("same project registration failed: %v", err) + } +} + +func TestImportClassesLegacyReportsMissingAndNonClassModules(t *testing.T) { + root := t.TempDir() + goMod := writeModule(t, root, "example.com/app", "") + loaded, err := modload.LoadFrom(goMod, "") + if err != nil { + t.Fatal(err) + } + loaded.Opt.ClassMods = []string{"example.com/missing"} + if err := New(loaded).ImportClasses(); err == nil || !IsNotFound(err) { + t.Fatalf("missing class module error = %v", err) + } + + noClassDir := filepath.Join(root, "no-class") + writeModule(t, noClassDir, "example.com/no-class", "") + if err := (&Module{}).importClassFrom(module.Version{Path: noClassDir}, nil); err != ErrNotClassFileMod { + t.Fatalf("non-class module error = %v, want %v", err, ErrNotClassFileMod) + } +} diff --git a/xgomod/resolved_identity.go b/xgomod/resolved_identity.go new file mode 100644 index 0000000..a3440a2 --- /dev/null +++ b/xgomod/resolved_identity.go @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xgomod + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "strings" +) + +func validateFileIdentity(identity FileIdentity) ([]byte, error) { + if identity.Path == "" || identity.SHA256 == "" { + return nil, fmt.Errorf("target modfile identity requires path and SHA-256") + } + if len(identity.SHA256) != sha256.Size*2 { + return nil, fmt.Errorf("target modfile SHA-256 must be %d hex characters", sha256.Size*2) + } + if _, err := hex.DecodeString(identity.SHA256); err != nil { + return nil, fmt.Errorf("invalid target modfile SHA-256: %w", err) + } + if identity.SHA256 != strings.ToLower(identity.SHA256) { + return nil, fmt.Errorf("target modfile SHA-256 must use lowercase hexadecimal") + } + path, err := canonicalSourcePath(identity.Path, "target modfile path", false) + if err != nil { + return nil, err + } + data, got, err := readFileSHA256(path) + if err != nil { + return nil, fmt.Errorf("read target modfile: %w", err) + } + if got != identity.SHA256 { + return nil, fmt.Errorf("target modfile SHA-256 mismatch for %s", identity.Path) + } + return data, nil +} + +func readFileSHA256(path string) ([]byte, string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, "", err + } + return data, sha256Hex(data), nil +} + +func sha256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} diff --git a/xgomod/resolved_import.go b/xgomod/resolved_import.go new file mode 100644 index 0000000..e584e6c --- /dev/null +++ b/xgomod/resolved_import.go @@ -0,0 +1,63 @@ +/* + * 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 xgomod + +import ( + "fmt" + "path/filepath" + + "github.com/goplus/mod/modload" +) + +func importResolvedModule(ref ResolvedModule) ([]*ProjectInfo, error) { + effective := ref.Effective() + goxmod := filepath.Join(effective.Dir, "gox.mod") + m, err := modload.LoadFrom(effective.GoMod, goxmod) + if err != nil { + return nil, err + } + if loadedPath := m.Path(); loadedPath != ref.Selected.Path && loadedPath != effective.Path { + return nil, fmt.Errorf("module source declares %q, graph selects %q", loadedPath, ref.Selected.Path) + } + projects := m.Projects() + if len(projects) == 0 { + return nil, ErrNotClassFileMod + } + infos := make([]*ProjectInfo, 0, len(projects)) + required := "" + if m.Opt != nil && m.Opt.XGo != nil { + required = m.Opt.XGo.Version + } + origin := cloneResolvedModule(ref) + declaration := m.GoxModIdentity() + if declaration.Path == "" || declaration.SHA256 == "" { + return nil, fmt.Errorf("module %q has projects without a declaring metadata snapshot", ref.Selected.Path) + } + for _, project := range projects { + infos = append(infos, &ProjectInfo{Project: project, Origin: origin, Declaration: declaration, RequiredXGo: required}) + } + return infos, nil +} + +func cloneResolvedModule(m ResolvedModule) *ResolvedModule { + c := m + if m.Replace != nil { + r := *m.Replace + c.Replace = &r + } + return &c +} diff --git a/xgomod/resolved_import_test.go b/xgomod/resolved_import_test.go new file mode 100644 index 0000000..d620b2f --- /dev/null +++ b/xgomod/resolved_import_test.go @@ -0,0 +1,291 @@ +/* + * 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 xgomod + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/goplus/mod/modload" +) + +func TestImportClassesResolvedProvenanceAndSelfOverlap(t *testing.T) { + root := t.TempDir() + targetGox := `xgo 1.9 + +project .foo Game example.com/app +class .foo Sprite + driver v1 example.com/app/cmd/driver +` + targetGoMod := writeModule(t, root, "example.com/app", targetGox) + if err := os.WriteFile(targetGoMod, []byte("module example.com/app\n\ngo 1.25\n\nrequire example.com/class v1.2.3 //xgo:class\n"), 0644); err != nil { + t.Fatal(err) + } + dep := filepath.Join(root, "dep") + depGox := `xgo 1.8 + +project .dep Dep example.com/class +` + depGoMod := writeModule(t, dep, "example.com/class", depGox) + + loaded, err := modload.LoadFrom(targetGoMod, filepath.Join(root, "gox.mod")) + if err != nil { + t.Fatal(err) + } + // The graph is deliberately supplied independently of the receiver's + // legacy ClassMods field. It must be the sole source of imported classes. + loaded.Opt.ClassMods = []string{"example.com/class"} + m := New(loaded) + target := graphModule("example.com/app", "", root, targetGoMod, true) + depRecord := graphModule("example.com/class", "v1.2.3", dep, depGoMod, false) + graph := ResolvedClassGraph{ + Target: target, + ClassModules: []ResolvedModule{depRecord}, + TargetModFile: graphIdentity(t, targetGoMod), + } + if err := m.ImportClassesResolved(graph); err != nil { + t.Fatal(err) + } + targetInfo, ok := m.LookupClassInfo(".foo") + if !ok || targetInfo.Project.Driver == nil { + t.Fatalf("target info = %#v, ok=%v", targetInfo, ok) + } + if targetInfo.Origin == nil || targetInfo.Origin.Selected.Path != "example.com/app" || targetInfo.RequiredXGo != "1.9" { + t.Fatalf("target provenance = %#v", targetInfo) + } + if targetInfo.Declaration != graphIdentity(t, filepath.Join(root, "gox.mod")) { + t.Fatalf("target declaration = %#v", targetInfo.Declaration) + } + workInfo, ok := m.LookupClassInfo(".foo") + if !ok || workInfo != targetInfo { + t.Fatal("project and work extension must share one ProjectInfo") + } + depInfo, ok := m.LookupClassInfo(".dep") + if !ok || depInfo.Origin == nil || depInfo.Origin.Selected.Path != "example.com/class" || depInfo.RequiredXGo != "1.8" { + t.Fatalf("dep provenance = %#v", depInfo) + } + if depInfo.Declaration != graphIdentity(t, filepath.Join(dep, "gox.mod")) { + t.Fatalf("dependency declaration = %#v", depInfo.Declaration) + } + builtin, ok := m.LookupClassInfo(".gsh") + if !ok || builtin.Origin != nil || builtin.Declaration != (FileIdentity{}) || builtin.RequiredXGo != "" { + t.Fatalf("builtin provenance = %#v", builtin) + } +} + +func TestImportClassesResolvedUsesGraphClassModules(t *testing.T) { + root := t.TempDir() + targetGoMod := writeModule(t, root, "example.com/app", "xgo 1.9\nproject .foo Game example.com/app\n") + loaded, err := modload.LoadFrom(targetGoMod, filepath.Join(root, "gox.mod")) + if err != nil { + t.Fatal(err) + } + // A stale receiver value must not cause an import when the graph says no + // class module is selected. + loaded.Opt.ClassMods = []string{"example.com/missing"} + m := New(loaded) + target := graphModule("example.com/app", "", root, targetGoMod, true) + graph := ResolvedClassGraph{Target: target, TargetModFile: graphIdentity(t, targetGoMod)} + if err := m.ImportClassesResolved(graph); err != nil { + t.Fatal(err) + } + if _, ok := m.LookupClass(".missing"); ok { + t.Fatal("stale ClassMods imported a class module") + } +} + +func TestImportClassesResolvedPreservesClassModuleOrder(t *testing.T) { + root := t.TempDir() + targetGoMod := writeModule(t, root, "example.com/app", "") + if err := os.WriteFile(targetGoMod, []byte(`module example.com/app + +go 1.25 + +require ( + example.com/second v1.0.0 //gop:class payload + example.com/first v1.0.0 //xgo:class +) +`), 0644); err != nil { + t.Fatal(err) + } + secondDir := filepath.Join(root, "second") + secondGoMod := writeModule(t, secondDir, "example.com/second", "xgo 1.9\nproject .shared Second example.com/second\n") + firstDir := filepath.Join(root, "first") + firstGoMod := writeModule(t, firstDir, "example.com/first", "xgo 1.9\nproject .shared First example.com/first\n") + loaded, err := modload.LoadFrom(targetGoMod, filepath.Join(root, "gox.mod")) + if err != nil { + t.Fatal(err) + } + target := graphModule("example.com/app", "", root, targetGoMod, true) + second := graphModule("example.com/second", "v1.0.0", secondDir, secondGoMod, false) + first := graphModule("example.com/first", "v1.0.0", firstDir, firstGoMod, false) + graph := ResolvedClassGraph{ + Target: target, ClassModules: []ResolvedModule{second, first}, TargetModFile: graphIdentity(t, targetGoMod), + } + m := New(loaded) + if err := m.ImportClassesResolved(graph); err != nil { + t.Fatal(err) + } + info, ok := m.LookupClassInfo(".shared") + if !ok || info.Origin == nil || info.Origin.Selected.Path != "example.com/first" { + t.Fatalf("shared class = %#v, ok=%v", info, ok) + } +} + +func TestImportClassesResolvedAllowsAbsentTargetGoxMod(t *testing.T) { + root := t.TempDir() + targetGoMod := writeModule(t, root, "example.com/app", "") + if err := os.WriteFile(targetGoMod, []byte("module example.com/app\n\ngo 1.25\n\nrequire example.com/framework v1.2.3 //xgo:class\n"), 0644); err != nil { + t.Fatal(err) + } + dep := filepath.Join(root, "framework") + depGoMod := writeModule(t, dep, "example.com/framework", "xgo 1.8\nproject .foo Framework example.com/framework\n") + loaded, err := modload.LoadFrom(targetGoMod, filepath.Join(root, "gox.mod")) + if err != nil { + t.Fatal(err) + } + m := New(loaded) + target := graphModule("example.com/app", "", root, targetGoMod, true) + depRecord := graphModule("example.com/framework", "v1.2.3", dep, depGoMod, false) + graph := ResolvedClassGraph{ + Target: target, + ClassModules: []ResolvedModule{depRecord}, + TargetModFile: graphIdentity(t, targetGoMod), + } + if err := m.ImportClassesResolved(graph); err != nil { + t.Fatal(err) + } + info, ok := m.LookupClassInfo(".foo") + if !ok || info.Origin == nil || info.Origin.Selected.Path != "example.com/framework" { + t.Fatalf("framework info = %#v, ok=%v", info, ok) + } +} + +func TestImportClassesResolvedModuleCacheSplitGoMod(t *testing.T) { + root := t.TempDir() + targetGoMod := writeModule(t, root, "example.com/app", "") + const ( + frameworkPath = "example.com/Framework" + frameworkVersion = "v1.2.3" + ) + if err := os.WriteFile(targetGoMod, []byte("module example.com/app\n\ngo 1.25\n\nrequire "+frameworkPath+" "+frameworkVersion+" //xgo:class\n"), 0644); err != nil { + t.Fatal(err) + } + frameworkDir, frameworkGoMod := writeModuleCacheSource(t, filepath.Join(root, "modcache"), frameworkPath, frameworkVersion, + "xgo 1.8\nproject .foo Framework example.com/framework\n") + loaded, err := modload.LoadFrom(targetGoMod, filepath.Join(root, "gox.mod")) + if err != nil { + t.Fatal(err) + } + m := New(loaded) + target := graphModule("example.com/app", "", root, targetGoMod, true) + framework := graphModule(frameworkPath, frameworkVersion, frameworkDir, frameworkGoMod, false) + graph := ResolvedClassGraph{ + Target: target, + ClassModules: []ResolvedModule{framework}, + TargetModFile: graphIdentity(t, targetGoMod), + } + if err := m.ImportClassesResolved(graph); err != nil { + t.Fatal(err) + } + info, ok := m.LookupClassInfo(".foo") + if !ok || info.Origin == nil { + t.Fatalf("framework info = %#v, ok=%v", info, ok) + } + effective := info.Origin.Effective() + canonicalGoMod, err := filepath.EvalSymlinks(frameworkGoMod) + if err != nil { + t.Fatal(err) + } + if effective.Path != frameworkPath || effective.Version != frameworkVersion || effective.GoMod != canonicalGoMod { + t.Fatalf("effective origin = %#v", effective) + } +} + +func TestResolvedGraphRejectsUnrelatedExternalGoMod(t *testing.T) { + root := t.TempDir() + const ( + modulePath = "example.com/Framework" + moduleVersion = "v1.2.3" + ) + dir, goMod := writeModuleCacheSource(t, filepath.Join(root, "cache-a"), modulePath, moduleVersion, "") + _, otherGoMod := writeModuleCacheSource(t, filepath.Join(root, "cache-b"), modulePath, moduleVersion, "") + canonicalDir, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + canonicalGoMod, err := filepath.EvalSymlinks(goMod) + if err != nil { + t.Fatal(err) + } + canonicalOther, err := filepath.EvalSymlinks(otherGoMod) + if err != nil { + t.Fatal(err) + } + externalGoMod := filepath.Join(root, "external.mod") + if err := os.WriteFile(externalGoMod, []byte("module "+modulePath+"\n"), 0644); err != nil { + t.Fatal(err) + } + canonicalExternal, err := filepath.EvalSymlinks(externalGoMod) + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + ref ModuleRef + match string + }{ + { + name: "different cache root", + ref: ModuleRef{Path: modulePath, Version: moduleVersion, Dir: canonicalDir, GoMod: canonicalOther}, + match: "download-cache identity", + }, + { + name: "arbitrary external go.mod", + ref: ModuleRef{Path: modulePath, Version: moduleVersion, Dir: canonicalDir, GoMod: canonicalExternal}, + match: "download-cache identity", + }, + { + name: "wrong logical version", + ref: ModuleRef{Path: modulePath, Version: "v1.2.4", Dir: canonicalDir, GoMod: canonicalGoMod}, + match: "source directory does not match", + }, + { + name: "local source cannot split", + ref: ModuleRef{Path: modulePath, Dir: canonicalDir, GoMod: canonicalGoMod}, + match: "non-main module selected version must not be empty", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := (ResolvedModule{Selected: tt.ref}).Validate() + if err == nil || !strings.Contains(err.Error(), tt.match) { + t.Fatalf("error = %v, want substring %q", err, tt.match) + } + }) + } + badDir, badGoMod := writeModuleCacheSource(t, filepath.Join(root, "cache-content"), modulePath, moduleVersion, "") + if err := os.WriteFile(badGoMod, []byte("module example.com/Other\n"), 0644); err != nil { + t.Fatal(err) + } + badRecord := graphModule(modulePath, moduleVersion, badDir, badGoMod, false) + if err := badRecord.Validate(); err == nil || !strings.Contains(err.Error(), "declares") { + t.Fatalf("mismatched module declaration error = %v", err) + } +} diff --git a/xgomod/resolved_module_test.go b/xgomod/resolved_module_test.go new file mode 100644 index 0000000..fe279cc --- /dev/null +++ b/xgomod/resolved_module_test.go @@ -0,0 +1,361 @@ +/* + * 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 xgomod + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolvedModuleEffective(t *testing.T) { + selected := ModuleRef{Path: "example.com/framework", Version: "v1.2.3"} + local := ModuleRef{Path: "/tmp/framework", Dir: "/tmp/framework", GoMod: "/tmp/framework/go.mod"} + resolved := ResolvedModule{Selected: selected, Replace: &local} + if got := resolved.Effective(); got != local { + t.Fatalf("Effective = %#v, want %#v", got, local) + } + if !resolved.IsLocal() { + t.Fatal("local replacement is not local") + } + resolved.Replace = &ModuleRef{Path: "example.com/fork", Version: "v1.0.0"} + if resolved.IsLocal() { + t.Fatal("versioned replacement is local") + } + if !((ResolvedModule{Main: true}).IsLocal()) { + t.Fatal("main module is not local") + } +} + +func TestResolvedModuleValidateSyntaxDoesNotReadFilesystem(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing") + resolved := ResolvedModule{Selected: ModuleRef{ + Path: "example.com/framework", Version: "v1.2.3", + Dir: missing, GoMod: filepath.Join(missing, "go.mod"), + }} + if err := resolved.ValidateSyntax(); err != nil { + t.Fatalf("ValidateSyntax consulted ambient filesystem: %v", err) + } + if err := resolved.Validate(); err == nil { + t.Fatal("Validate accepted a missing effective source") + } +} + +func TestResolvedModuleValidateSyntaxRejectsImpossibleGraphStates(t *testing.T) { + tests := map[string]ResolvedModule{ + "main with version": { + Selected: ModuleRef{Path: "example.com/framework", Version: "v1.2.3", Dir: "/workspace/framework", GoMod: "/workspace/framework/go.mod"}, Main: true, + }, + "main with replacement": { + Selected: ModuleRef{Path: "example.com/framework", Dir: "/workspace/framework", GoMod: "/workspace/framework/go.mod"}, Main: true, + Replace: &ModuleRef{Path: "/workspace/local", Dir: "/workspace/local", GoMod: "/workspace/local/go.mod"}, + }, + "non-main without version": { + Selected: ModuleRef{Path: "example.com/framework", Dir: "/workspace/framework", GoMod: "/workspace/framework/go.mod"}, + }, + "local replacement with module path": { + Selected: ModuleRef{Path: "example.com/framework", Version: "v1.2.3"}, + Replace: &ModuleRef{Path: "example.com/fork", Dir: "/workspace/fork", GoMod: "/workspace/fork/go.mod"}, + }, + "local replacement path differs from dir": { + Selected: ModuleRef{Path: "example.com/framework", Version: "v1.2.3"}, + Replace: &ModuleRef{Path: "/workspace/fork", Dir: "/workspace/other", GoMod: "/workspace/other/go.mod"}, + }, + "versioned replacement with filesystem path": { + Selected: ModuleRef{Path: "example.com/framework", Version: "v1.2.3"}, + Replace: &ModuleRef{Path: "/workspace/fork", Version: "v1.4.0", Dir: "/workspace/fork", GoMod: "/workspace/fork/go.mod"}, + }, + } + for name, resolved := range tests { + t.Run(name, func(t *testing.T) { + if err := resolved.ValidateSyntax(); err == nil { + t.Fatal("ValidateSyntax accepted impossible graph state") + } + }) + } +} + +func TestResolvedModuleValidateDirectAndReplacements(t *testing.T) { + directDir := filepath.Join(t.TempDir(), "direct") + directGoMod := writeModule(t, directDir, "example.com/direct", "") + direct := graphModule("example.com/direct", "v1.2.3", directDir, directGoMod, false) + + localDir := filepath.Join(t.TempDir(), "local") + localGoMod := writeModule(t, localDir, "example.com/local", "") + localDir, err := filepath.EvalSymlinks(localDir) + if err != nil { + t.Fatal(err) + } + localGoMod, err = filepath.EvalSymlinks(localGoMod) + if err != nil { + t.Fatal(err) + } + localReplace := ResolvedModule{ + Selected: ModuleRef{Path: "example.com/original", Version: "v1.2.3"}, + Replace: &ModuleRef{Path: localDir, Dir: localDir, GoMod: localGoMod}, + } + + versionDir := filepath.Join(t.TempDir(), "version") + versionGoMod := writeModule(t, versionDir, "example.com/fork", "") + versionReplaceSource := graphModule("example.com/fork", "v1.4.0", versionDir, versionGoMod, false).Selected + versionReplace := ResolvedModule{ + Selected: ModuleRef{Path: "example.com/original", Version: "v1.2.3"}, + Replace: &versionReplaceSource, + } + + for name, resolved := range map[string]ResolvedModule{ + "direct": direct, + "local replacement": localReplace, + "version replacement": versionReplace, + } { + t.Run(name, func(t *testing.T) { + if err := resolved.Validate(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestResolvedModuleValidateOfficialModuleCacheSplit(t *testing.T) { + const ( + path = "example.com/Upper/legacy" + version = "v1.2.3" + ) + dir, goMod := writeModuleCacheSource(t, filepath.Join(t.TempDir(), "pkg", "mod"), path, version, "") + resolved := graphModule(path, version, dir, goMod, false) + if err := resolved.Validate(); err != nil { + t.Fatal(err) + } +} + +func TestResolvedModuleValidateRejectsFilesystemAndIdentityShapes(t *testing.T) { + root := t.TempDir() + goMod := writeModule(t, root, "example.com/app", "") + canonicalDir, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + canonicalGoMod, err := filepath.EvalSymlinks(goMod) + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + resolved ResolvedModule + want string + }{ + { + name: "directory used as go.mod", + resolved: ResolvedModule{Selected: ModuleRef{ + Path: "example.com/app", Version: "v1.0.0", Dir: canonicalDir, GoMod: canonicalDir, + }}, + want: "not a regular file", + }, + { + name: "file used as module directory", + resolved: ResolvedModule{Selected: ModuleRef{ + Path: "example.com/app", Version: "v1.0.0", Dir: canonicalGoMod, GoMod: canonicalGoMod, + }}, + want: "not a directory", + }, + { + name: "invalid module path", + resolved: ResolvedModule{Selected: ModuleRef{ + Path: "../app", Version: "v1.0.0", Dir: canonicalDir, GoMod: canonicalGoMod, + }}, + want: "invalid module path", + }, + { + name: "non-canonical version", + resolved: ResolvedModule{Selected: ModuleRef{ + Path: "example.com/app", Version: "v1", Dir: canonicalDir, GoMod: canonicalGoMod, + }}, + want: "invalid non-canonical version", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.resolved.Validate() + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() error = %v, want substring %q", err, test.want) + } + }) + } +} + +func TestResolvedModuleValidateRejectsReplacementAndCanonicalShapes(t *testing.T) { + root := t.TempDir() + goMod := writeModule(t, root, "example.com/app", "") + canonicalDir, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + canonicalGoMod, err := filepath.EvalSymlinks(goMod) + if err != nil { + t.Fatal(err) + } + validSource := func() ModuleRef { + return ModuleRef{Path: "example.com/app", Version: "v1.0.0", Dir: canonicalDir, GoMod: canonicalGoMod} + } + validSelected := func() ModuleRef { + return ModuleRef{Path: "example.com/app", Version: "v1.0.0"} + } + for _, test := range []struct { + name string + resolved ResolvedModule + want string + }{ + {name: "empty selected path", resolved: ResolvedModule{Selected: ModuleRef{Version: "v1.0.0", Dir: canonicalDir, GoMod: canonicalGoMod}}, want: "module path is empty"}, + {name: "invalid selected major", resolved: ResolvedModule{Selected: ModuleRef{Path: "example.com/app/v2", Version: "v1.0.0", Dir: canonicalDir, GoMod: canonicalGoMod}}, want: "invalid module version"}, + {name: "missing source", resolved: ResolvedModule{Selected: ModuleRef{Path: "example.com/app", Version: "v1.0.0"}}, want: "must provide both"}, + {name: "relative source", resolved: ResolvedModule{Selected: ModuleRef{Path: "example.com/app", Version: "v1.0.0", Dir: "relative", GoMod: canonicalGoMod}}, want: "absolute clean path"}, + {name: "empty replacement path", resolved: ResolvedModule{Selected: validSelected(), Replace: &ModuleRef{Version: "v1.0.0", Dir: canonicalDir, GoMod: canonicalGoMod}}, want: "replacement path is empty"}, + {name: "invalid replacement path", resolved: ResolvedModule{Selected: validSelected(), Replace: &ModuleRef{Path: "bad path", Version: "v1.0.0", Dir: canonicalDir, GoMod: canonicalGoMod}}, want: "invalid module path"}, + {name: "invalid replacement version", resolved: ResolvedModule{Selected: validSelected(), Replace: &ModuleRef{Path: "example.com/fork", Version: "v1", Dir: canonicalDir, GoMod: canonicalGoMod}}, want: "invalid non-canonical version"}, + } { + t.Run(test.name, func(t *testing.T) { + if err := test.resolved.Validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() error = %v, want substring %q", err, test.want) + } + }) + } + + t.Run("non-canonical directory", func(t *testing.T) { + alias := filepath.Join(filepath.Dir(canonicalDir), "xgomod-resolved-dir-alias") + makeSymlink(t, canonicalDir, alias) + defer os.Remove(alias) + ref := validSource() + ref.Dir = alias + if err := (ResolvedModule{Selected: ref}).Validate(); err == nil || !strings.Contains(err.Error(), "Dir must be canonical") { + t.Fatalf("non-canonical directory error = %v", err) + } + }) + + t.Run("non-canonical go.mod", func(t *testing.T) { + alias := filepath.Join(canonicalDir, "xgomod-resolved-go.mod-alias") + makeSymlink(t, canonicalGoMod, alias) + defer os.Remove(alias) + ref := validSource() + ref.GoMod = alias + if err := (ResolvedModule{Selected: ref}).Validate(); err == nil || !strings.Contains(err.Error(), "GoMod must be canonical") { + t.Fatalf("non-canonical go.mod error = %v", err) + } + }) + + t.Run("non-canonical local replacement path", func(t *testing.T) { + alias := filepath.Join(filepath.Dir(canonicalDir), "xgomod-replacement-dir-alias") + makeSymlink(t, canonicalDir, alias) + defer os.Remove(alias) + replacement := &ModuleRef{Path: alias, Dir: alias, GoMod: filepath.Join(alias, "go.mod")} + resolved := ResolvedModule{Selected: validSelected(), Replace: replacement} + if err := resolved.Validate(); err == nil || !strings.Contains(err.Error(), "replacement.Dir must be canonical") { + t.Fatalf("non-canonical replacement path error = %v", err) + } + }) +} + +func TestValidateFileIdentityRejectsMalformedShapes(t *testing.T) { + root := t.TempDir() + goMod := writeModule(t, root, "example.com/app", "") + identity := graphIdentity(t, goMod) + for _, test := range []struct { + name string + identity FileIdentity + want string + }{ + {name: "missing fields", identity: FileIdentity{}, want: "requires path and SHA-256"}, + {name: "short digest", identity: FileIdentity{Path: goMod, SHA256: "abcd"}, want: "must be 64 hex characters"}, + {name: "non-hex digest", identity: FileIdentity{Path: goMod, SHA256: strings.Repeat("g", 64)}, want: "invalid target modfile SHA-256"}, + {name: "uppercase digest", identity: FileIdentity{Path: goMod, SHA256: strings.ToUpper(identity.SHA256)}, want: "must use lowercase"}, + {name: "relative path", identity: FileIdentity{Path: "go.mod", SHA256: identity.SHA256}, want: "target modfile path"}, + {name: "directory path", identity: FileIdentity{Path: root, SHA256: identity.SHA256}, want: "target modfile path"}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := validateFileIdentity(test.identity); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validateFileIdentity() error = %v, want substring %q", err, test.want) + } + }) + } + + t.Run("symlink path", func(t *testing.T) { + alias := filepath.Join(root, "go.mod.alias") + makeSymlink(t, goMod, alias) + defer os.Remove(alias) + if _, err := validateFileIdentity(FileIdentity{Path: alias, SHA256: identity.SHA256}); err == nil || !strings.Contains(err.Error(), "path must be canonical") { + t.Fatalf("symlink identity error = %v", err) + } + }) +} + +func TestReadFileSHA256ReturnsOneSnapshot(t *testing.T) { + path := filepath.Join(t.TempDir(), "snapshot.mod") + want := []byte("module example.com/snapshot\n") + if err := os.WriteFile(path, want, 0644); err != nil { + t.Fatal(err) + } + data, digest, err := readFileSHA256(path) + if err != nil { + t.Fatal(err) + } + if string(data) != string(want) { + t.Fatalf("snapshot data = %q, want %q", data, want) + } + if digest != sha256Hex(data) { + t.Fatalf("snapshot digest = %q, want digest of returned bytes", digest) + } +} + +func TestValidateModuleCacheSplitSourceRejectsMissingMetadata(t *testing.T) { + t.Run("missing download metadata", func(t *testing.T) { + path := "example.com/framework" + version := "v1.2.3" + dir, goMod := writeModuleCacheSource(t, filepath.Join(t.TempDir(), "modcache"), path, version, "") + dir, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(goMod); err != nil { + t.Fatal(err) + } + if err := validateModuleCacheSplitSource(ModuleRef{Path: path, Version: version}, dir, goMod); err == nil || !strings.Contains(err.Error(), "download-cache go.mod") { + t.Fatalf("missing metadata error = %v", err) + } + }) + + if err := validateModuleCacheSplitSource(ModuleRef{Path: "example.com/framework"}, "/", "/"); err == nil || !strings.Contains(err.Error(), "module has no version") { + t.Fatalf("missing version error = %v", err) + } +} + +func TestCloneResolvedModuleCopiesReplacement(t *testing.T) { + original := ResolvedModule{ + Selected: ModuleRef{Path: "example.com/framework", Version: "v1.2.3"}, + Replace: &ModuleRef{Path: "/workspace/framework", Dir: "/workspace/framework", GoMod: "/workspace/framework/go.mod"}, + } + clone := cloneResolvedModule(original) + if clone == nil { + t.Fatal("clone is nil") + } + if clone.Replace == nil || clone.Replace == original.Replace { + t.Fatalf("clone replacement pointer = %p, original = %p", clone.Replace, original.Replace) + } + clone.Replace.Path = "/workspace/other" + if original.Replace.Path == clone.Replace.Path { + t.Fatal("mutating clone changed original replacement") + } +} diff --git a/xgomod/resolved_receiver_test.go b/xgomod/resolved_receiver_test.go new file mode 100644 index 0000000..91640ae --- /dev/null +++ b/xgomod/resolved_receiver_test.go @@ -0,0 +1,373 @@ +/* + * 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 xgomod + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/goplus/mod/modfile" + "github.com/goplus/mod/modload" +) + +func TestImportClassesResolvedDriverBackedCollision(t *testing.T) { + root := t.TempDir() + targetGox := "xgo 1.9\nproject .foo Game example.com/app\ndriver v1 example.com/app/driver\n" + targetGoMod := writeModule(t, root, "example.com/app", targetGox) + if err := os.WriteFile(targetGoMod, []byte("module example.com/app\n\ngo 1.25\n\nrequire example.com/class v1.2.3 //xgo:class\n"), 0644); err != nil { + t.Fatal(err) + } + dep := filepath.Join(root, "dep") + depGox := "xgo 1.8\nproject .foo Other example.com/class\ndriver v1 example.com/class/driver\n" + depGoMod := writeModule(t, dep, "example.com/class", depGox) + loaded, err := modload.LoadFrom(targetGoMod, filepath.Join(root, "gox.mod")) + if err != nil { + t.Fatal(err) + } + m := New(loaded) + target := graphModule("example.com/app", "", root, targetGoMod, true) + depRecord := graphModule("example.com/class", "v1.2.3", dep, depGoMod, false) + graph := ResolvedClassGraph{Target: target, ClassModules: []ResolvedModule{depRecord}, TargetModFile: graphIdentity(t, targetGoMod)} + err = m.ImportClassesResolved(graph) + if err == nil || !strings.Contains(err.Error(), "driver-backed class extension collision") { + t.Fatalf("error = %v", err) + } +} + +func TestImportClassesResolvedRejectsChangedTargetSnapshots(t *testing.T) { + root := t.TempDir() + targetGox := "xgo 1.9\nproject .foo Game example.com/app\n" + targetGoMod := writeModule(t, root, "example.com/app", targetGox) + loaded, err := modload.LoadFrom(targetGoMod, filepath.Join(root, "gox.mod")) + if err != nil { + t.Fatal(err) + } + m := New(loaded) + target := graphModule("example.com/app", "", root, targetGoMod, true) + graph := ResolvedClassGraph{Target: target, TargetModFile: graphIdentity(t, targetGoMod)} + if err := os.WriteFile(filepath.Join(root, "gox.mod"), []byte("xgo 1.9\nproject .bar Changed example.com/app\n"), 0644); err != nil { + t.Fatal(err) + } + err = m.ImportClassesResolved(graph) + if err == nil || !strings.Contains(err.Error(), "target gox.mod contents changed") { + t.Fatalf("changed gox.mod error = %v", err) + } + + // Restore the gox snapshot, then replace go.mod at the same path. The + // graph digest and the receiver's load digest must reject the mix too. + if err := os.WriteFile(filepath.Join(root, "gox.mod"), []byte(targetGox), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(targetGoMod, []byte("module example.com/app\n\ngo 1.25\n\n// changed\n"), 0644); err != nil { + t.Fatal(err) + } + err = m.ImportClassesResolved(graph) + if err == nil || !strings.Contains(err.Error(), "target modfile SHA-256 mismatch") { + t.Fatalf("changed go.mod error = %v", err) + } +} + +func TestImportClassesResolvedRejectsInMemoryReceiverWithoutSnapshot(t *testing.T) { + root := t.TempDir() + targetGoMod := writeModule(t, root, "example.com/app", "xgo 1.9\nproject .foo Game example.com/app\n") + loaded, err := modload.LoadFrom(targetGoMod, filepath.Join(root, "gox.mod")) + if err != nil { + t.Fatal(err) + } + inMemory := modload.Module{File: loaded.File, Opt: loaded.Opt} + target := graphModule("example.com/app", "", root, targetGoMod, true) + graph := ResolvedClassGraph{Target: target, TargetModFile: graphIdentity(t, targetGoMod)} + if err := New(inMemory).ImportClassesResolved(graph); err == nil || !strings.Contains(err.Error(), "no target modfile snapshot") { + t.Fatalf("error = %v", err) + } +} + +func TestImportClassesResolvedRejectsReceiverState(t *testing.T) { + var nilModule *Module + if err := nilModule.ImportClassesResolved(ResolvedClassGraph{}); err == nil || !strings.Contains(err.Error(), "no target module snapshot") { + t.Fatalf("nil receiver error = %v", err) + } + if err := (&Module{}).ImportClassesResolved(ResolvedClassGraph{}); err == nil || !strings.Contains(err.Error(), "no target module snapshot") { + t.Fatalf("empty receiver error = %v", err) + } + + root := t.TempDir() + goMod := writeModule(t, root, "example.com/app", "xgo 1.9\n") + loaded, err := modload.LoadFrom(goMod, filepath.Join(root, "gox.mod")) + if err != nil { + t.Fatal(err) + } + m := New(loaded) + target := graphModule("example.com/other", "", root, goMod, true) + graph := ResolvedClassGraph{Target: target, TargetModFile: graphIdentity(t, goMod)} + if err := m.ImportClassesResolved(graph); err == nil || !strings.Contains(err.Error(), "does not match graph target") { + t.Fatalf("target mismatch error = %v", err) + } +} + +func TestImportClassesResolvedPreservesReceiverOnClassImportFailure(t *testing.T) { + root := t.TempDir() + goMod := writeModule(t, root, "example.com/app", "xgo 1.9\n") + if err := os.WriteFile(goMod, []byte("module example.com/app\n\ngo 1.25\n\nrequire example.com/dep v1.0.0 //xgo:class\n"), 0644); err != nil { + t.Fatal(err) + } + depDir := filepath.Join(root, "dep") + depGoMod := writeModule(t, depDir, "example.com/dep", "") + loaded, err := modload.LoadFrom(goMod, filepath.Join(root, "gox.mod")) + if err != nil { + t.Fatal(err) + } + m := New(loaded) + old := &Project{Ext: ".old", Class: "Old"} + m.projs = map[string]*Project{old.Ext: old} + m.infos = map[string]*ProjectInfo{old.Ext: {Project: old}} + + target := graphModule("example.com/app", "", root, goMod, true) + dep := graphModule("example.com/dep", "v1.0.0", depDir, depGoMod, false) + graph := ResolvedClassGraph{ + Target: target, + ClassModules: []ResolvedModule{dep}, + TargetModFile: graphIdentity(t, goMod), + } + if err := m.ImportClassesResolved(graph); err == nil || !strings.Contains(err.Error(), "not a classfile module") { + t.Fatalf("class import error = %v", err) + } + if got, ok := m.LookupClassInfo(old.Ext); !ok || got.Project != old { + t.Fatalf("receiver changed after failed import: %#v, ok=%v", got, ok) + } +} + +func TestImportClassesResolvedRejectsReceiverSnapshotMismatch(t *testing.T) { + t.Run("path", func(t *testing.T) { + root := t.TempDir() + goMod := writeModule(t, root, "example.com/app", "xgo 1.9\n") + copyPath := filepath.Join(root, "graph.go.mod") + data, err := os.ReadFile(goMod) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(copyPath, data, 0644); err != nil { + t.Fatal(err) + } + loaded, err := modload.LoadFrom(goMod, filepath.Join(root, "gox.mod")) + if err != nil { + t.Fatal(err) + } + graph := ResolvedClassGraph{ + Target: graphModule("example.com/app", "", root, goMod, true), + TargetModFile: graphIdentity(t, copyPath), + } + if err := New(loaded).ImportClassesResolved(graph); err == nil || !strings.Contains(err.Error(), "snapshots differ") { + t.Fatalf("path mismatch error = %v", err) + } + }) + + t.Run("content", func(t *testing.T) { + root := t.TempDir() + goMod := writeModule(t, root, "example.com/app", "xgo 1.9\n") + loadedSnapshot := []byte("module example.com/app\n\ngo 1.25\n\n// loaded snapshot\n") + loaded, err := modload.LoadFromEx(goMod, filepath.Join(root, "gox.mod"), func(path string) ([]byte, error) { + if path == goMod { + return loadedSnapshot, nil + } + return os.ReadFile(path) + }) + if err != nil { + t.Fatal(err) + } + graph := ResolvedClassGraph{ + Target: graphModule("example.com/app", "", root, goMod, true), + TargetModFile: graphIdentity(t, goMod), + } + if err := New(loaded).ImportClassesResolved(graph); err == nil || !strings.Contains(err.Error(), "contents differ") { + t.Fatalf("content mismatch error = %v", err) + } + }) +} + +func TestReceiverGoxSnapshotRequiresLoadedMetadata(t *testing.T) { + t.Run("absent", func(t *testing.T) { + root := t.TempDir() + goMod := writeModule(t, root, "example.com/app", "") + loaded, err := modload.LoadFrom(goMod, "") + if err != nil { + t.Fatal(err) + } + graph := ResolvedClassGraph{ + Target: graphModule("example.com/app", "", root, goMod, true), + TargetModFile: graphIdentity(t, goMod), + } + if err := New(loaded).ImportClassesResolved(graph); err != nil { + t.Fatalf("absent metadata should be accepted: %v", err) + } + }) + + t.Run("appeared", func(t *testing.T) { + root := t.TempDir() + goMod := writeModule(t, root, "example.com/app", "xgo 1.9\n") + goxMod := filepath.Join(root, "gox.mod") + loaded, err := modload.LoadFromEx(goMod, goxMod, func(path string) ([]byte, error) { + if path == goxMod { + return nil, os.ErrPermission + } + return os.ReadFile(path) + }) + if err != nil { + t.Fatal(err) + } + graph := ResolvedClassGraph{ + Target: graphModule("example.com/app", "", root, goMod, true), + TargetModFile: graphIdentity(t, goMod), + } + if err := New(loaded).ImportClassesResolved(graph); err == nil || !strings.Contains(err.Error(), "appeared without load snapshot") { + t.Fatalf("appeared metadata error = %v", err) + } + }) +} + +func TestReceiverGoxSnapshotRejectsUntrustedIdentity(t *testing.T) { + t.Run("relative path", func(t *testing.T) { + loaded, err := modload.LoadFromEx("relative/go.mod", "relative/gox.mod", func(path string) ([]byte, error) { + switch path { + case "relative/go.mod": + return []byte("module example.com/app\n\ngo 1.25\n"), nil + case "relative/gox.mod": + return []byte("xgo 1.9\n"), nil + default: + return nil, os.ErrNotExist + } + }) + if err != nil { + t.Fatal(err) + } + if _, _, err := receiverGoxSnapshot(New(loaded), t.TempDir()); err == nil || !strings.Contains(err.Error(), "path must be absolute") { + t.Fatalf("relative identity error = %v", err) + } + }) + + t.Run("outside target source", func(t *testing.T) { + root := t.TempDir() + goMod := writeModule(t, root, "example.com/app", "") + outside := filepath.Join(t.TempDir(), "gox.mod") + if err := os.WriteFile(outside, []byte("xgo 1.9\n"), 0644); err != nil { + t.Fatal(err) + } + loaded, err := modload.LoadFrom(goMod, outside) + if err != nil { + t.Fatal(err) + } + if _, _, err := receiverGoxSnapshot(New(loaded), root); err == nil || !strings.Contains(err.Error(), "outside graph target source") { + t.Fatalf("outside identity error = %v", err) + } + }) + + t.Run("projects without snapshot", func(t *testing.T) { + root := t.TempDir() + goMod := writeModule(t, root, "example.com/app", "") + loaded, err := modload.LoadFrom(goMod, "") + if err != nil { + t.Fatal(err) + } + loaded.Opt.Projects = []*modfile.Project{{Ext: ".foo", Class: "Game"}} + if _, _, err := receiverGoxSnapshot(New(loaded), root); err == nil || !strings.Contains(err.Error(), "projects without") { + t.Fatalf("projects without snapshot error = %v", err) + } + }) + + t.Run("declaration disappeared", func(t *testing.T) { + root := t.TempDir() + goMod := writeModule(t, root, "example.com/app", "xgo 1.9\n") + goxMod := filepath.Join(root, "gox.mod") + loaded, err := modload.LoadFrom(goMod, goxMod) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(goxMod); err != nil { + t.Fatal(err) + } + if _, _, err := receiverGoxSnapshot(New(loaded), root); err == nil || !strings.Contains(err.Error(), "receiver target gox.mod") { + t.Fatalf("disappeared declaration error = %v", err) + } + }) +} + +func TestImportClassesResolvedRejectsRelativeReceiverModfile(t *testing.T) { + root := t.TempDir() + graphGoMod := writeModule(t, root, "example.com/app", "") + loaded, err := modload.LoadFromEx("relative/go.mod", "", func(path string) ([]byte, error) { + if path == "relative/go.mod" { + return []byte("module example.com/app\n\ngo 1.25\n"), nil + } + return nil, os.ErrNotExist + }) + if err != nil { + t.Fatal(err) + } + target := graphModule("example.com/app", "", root, graphGoMod, true) + graph := ResolvedClassGraph{Target: target, TargetModFile: graphIdentity(t, graphGoMod)} + if err := New(loaded).ImportClassesResolved(graph); err == nil || !strings.Contains(err.Error(), "receiver target modfile") { + t.Fatalf("relative receiver modfile error = %v", err) + } +} + +func TestResolvedGraphRejectsMalformedTargetAndMismatchedSource(t *testing.T) { + t.Run("malformed target modfile", func(t *testing.T) { + root := t.TempDir() + goMod := writeModule(t, root, "example.com/app", "") + if err := os.WriteFile(goMod, []byte("module example.com/app\n\nrequire (\n"), 0644); err != nil { + t.Fatal(err) + } + graph := ResolvedClassGraph{ + Target: graphModule("example.com/app", "", root, goMod, true), + TargetModFile: graphIdentity(t, goMod), + } + if err := graph.validate(); err == nil || !strings.Contains(err.Error(), "parse target modfile") { + t.Fatalf("malformed target error = %v", err) + } + }) + + t.Run("source declares another module", func(t *testing.T) { + root := t.TempDir() + targetGoMod := writeModule(t, root, "example.com/app", "") + if err := os.WriteFile(targetGoMod, []byte("module example.com/app\n\ngo 1.25\n\nrequire example.com/dep v1.0.0 //xgo:class\n"), 0644); err != nil { + t.Fatal(err) + } + depDir := filepath.Join(root, "dep") + depGoMod := writeModule(t, depDir, "example.com/wrong", "xgo 1.9\nproject .dep Dep example.com/wrong\n") + loaded, err := modload.LoadFrom(targetGoMod, "") + if err != nil { + t.Fatal(err) + } + m := New(loaded) + old := &Project{Ext: ".old", Class: "Old"} + m.projs = map[string]*Project{old.Ext: old} + m.infos = map[string]*ProjectInfo{old.Ext: {Project: old}} + graph := ResolvedClassGraph{ + Target: graphModule("example.com/app", "", root, targetGoMod, true), + ClassModules: []ResolvedModule{graphModule("example.com/dep", "v1.0.0", depDir, depGoMod, false)}, + TargetModFile: graphIdentity(t, targetGoMod), + } + if err := m.ImportClassesResolved(graph); err == nil || !strings.Contains(err.Error(), "declares") { + t.Fatalf("source identity error = %v", err) + } + if got, ok := m.LookupClassInfo(old.Ext); !ok || got.Project != old { + t.Fatalf("receiver changed after source identity error: %#v, ok=%v", got, ok) + } + }) +} diff --git a/xgomod/resolved_source.go b/xgomod/resolved_source.go new file mode 100644 index 0000000..b4b2fee --- /dev/null +++ b/xgomod/resolved_source.go @@ -0,0 +1,149 @@ +/* + * 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 xgomod + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + gomodfile "golang.org/x/mod/modfile" + "golang.org/x/mod/module" +) + +func canonicalPath(path string, wantDir bool) (string, error) { + if path == "" || !filepath.IsAbs(path) { + return "", fmt.Errorf("path must be absolute: %q", path) + } + path = filepath.Clean(path) + info, err := os.Stat(path) + if err != nil { + return "", err + } + if wantDir && !info.IsDir() { + return "", fmt.Errorf("path is not a directory: %s", path) + } + if !wantDir && (info.IsDir() || !info.Mode().IsRegular()) { + return "", fmt.Errorf("path is not a regular file: %s", path) + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return "", err + } + resolved, err = filepath.Abs(resolved) + if err != nil { + return "", err + } + return filepath.Clean(resolved), nil +} + +func validateSourceFiles(ref ModuleRef, label string) error { + canonDir, err := canonicalSourcePath(ref.Dir, label+".Dir", true) + if err != nil { + return err + } + canonGoMod, err := canonicalSourcePath(ref.GoMod, label+".GoMod", false) + if err != nil { + return err + } + if pathWithin(canonDir, canonGoMod) { + return nil + } + if err := validateModuleCacheSplitSource(ref, canonDir, canonGoMod); err != nil { + return fmt.Errorf("%s.GoMod must be inside %s or be matching Go module-cache metadata: %w", label, canonDir, err) + } + return nil +} + +func canonicalSourcePath(value, label string, wantDir bool) (string, error) { + canonical, err := canonicalPath(value, wantDir) + if err != nil { + return "", fmt.Errorf("%s: %w", label, err) + } + if filepath.Clean(value) != canonical { + return "", fmt.Errorf("%s must be canonical: %q", label, value) + } + return canonical, nil +} + +func validateSourceSyntax(ref ModuleRef, label string) error { + if ref.Dir == "" || ref.GoMod == "" { + return fmt.Errorf("%s must provide both Dir and GoMod", label) + } + for _, item := range []struct { + field string + value string + }{{"Dir", ref.Dir}, {"GoMod", ref.GoMod}} { + if !isAbsoluteCleanPath(item.value) { + return fmt.Errorf("%s.%s must be an absolute clean path: %q", label, item.field, item.value) + } + } + return nil +} + +func isAbsoluteCleanPath(value string) bool { + return filepath.IsAbs(value) && filepath.Clean(value) == value && strings.IndexByte(value, 0) < 0 +} + +func validateModuleCacheSplitSource(ref ModuleRef, dir, goMod string) error { + if ref.Version == "" { + return fmt.Errorf("module has no version") + } + escapedPath, err := module.EscapePath(ref.Path) + if err != nil { + return fmt.Errorf("escape module path: %w", err) + } + escapedVersion, err := module.EscapeVersion(ref.Version) + if err != nil { + return fmt.Errorf("escape module version: %w", err) + } + sourceSuffix := filepath.FromSlash(escapedPath) + "@" + escapedVersion + cacheRoot := dir + for range strings.Split(sourceSuffix, string(filepath.Separator)) { + parent := filepath.Dir(cacheRoot) + if parent == cacheRoot { + return fmt.Errorf("source directory does not have module-cache layout") + } + cacheRoot = parent + } + expectedDir := filepath.Join(cacheRoot, sourceSuffix) + if !sameCanonicalPath(expectedDir, dir, true) { + return fmt.Errorf("source directory does not match %s@%s module-cache identity", ref.Path, ref.Version) + } + expectedGoMod := filepath.Join(cacheRoot, "cache", "download", filepath.FromSlash(escapedPath), "@v", escapedVersion+".mod") + expectedInfo, err := os.Lstat(expectedGoMod) + if err != nil || expectedInfo.Mode()&os.ModeSymlink != 0 || !expectedInfo.Mode().IsRegular() { + return fmt.Errorf("download-cache go.mod is not a regular non-symlink file") + } + if !sameCanonicalPath(expectedGoMod, goMod, false) { + return fmt.Errorf("go.mod does not match %s@%s download-cache identity", ref.Path, ref.Version) + } + b, err := os.ReadFile(goMod) + if err != nil { + return fmt.Errorf("read download-cache go.mod: %w", err) + } + if declared := gomodfile.ModulePath(b); declared != ref.Path { + return fmt.Errorf("download-cache go.mod declares %q, want %q", declared, ref.Path) + } + return nil +} + +func sameCanonicalPath(expected, actual string, wantDir bool) bool { + canonical, err := canonicalPath(expected, wantDir) + return err == nil && canonical == actual +} diff --git a/xgomod/resolved_test.go b/xgomod/resolved_test.go new file mode 100644 index 0000000..ae532a2 --- /dev/null +++ b/xgomod/resolved_test.go @@ -0,0 +1,136 @@ +/* + * 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 xgomod + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "syscall" + "testing" + + "golang.org/x/mod/module" +) + +func writeModule(t *testing.T, dir, modPath, gox string) string { + t.Helper() + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + goMod := filepath.Join(dir, "go.mod") + if err := os.WriteFile(goMod, []byte("module "+modPath+"\n\ngo 1.25\n"), 0644); err != nil { + t.Fatal(err) + } + if gox != "" { + if err := os.WriteFile(filepath.Join(dir, "gox.mod"), []byte(gox), 0644); err != nil { + t.Fatal(err) + } + } + return goMod +} + +func graphModule(path, version, dir, goMod string, main bool) ResolvedModule { + canonicalDir, err := filepath.EvalSymlinks(dir) + if err != nil { + panic(err) + } + canonicalGoMod, err := filepath.EvalSymlinks(goMod) + if err != nil { + panic(err) + } + return ResolvedModule{ + Selected: ModuleRef{Path: path, Version: version, Dir: canonicalDir, GoMod: canonicalGoMod}, + Main: main, + } +} + +func graphIdentity(t *testing.T, path string) FileIdentity { + t.Helper() + canonical, err := filepath.EvalSymlinks(path) + if err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(canonical) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(b) + return FileIdentity{Path: canonical, SHA256: hex.EncodeToString(sum[:])} +} + +func makeSymlink(t *testing.T, oldname, newname string) { + t.Helper() + if err := os.Symlink(oldname, newname); err != nil { + if errors.Is(err, os.ErrPermission) || errors.Is(err, errors.ErrUnsupported) || errors.Is(err, syscall.Errno(1314)) { + t.Skipf("symlink unavailable: %v", err) + } + t.Fatal(err) + } +} + +func writeModuleCacheSource(t *testing.T, cacheRoot, modPath, version, gox string) (dir, goMod string) { + t.Helper() + escapedPath, err := module.EscapePath(modPath) + if err != nil { + t.Fatal(err) + } + escapedVersion, err := module.EscapeVersion(version) + if err != nil { + t.Fatal(err) + } + dir = filepath.Join(cacheRoot, filepath.FromSlash(escapedPath)+"@"+escapedVersion) + goMod = filepath.Join(cacheRoot, "cache", "download", filepath.FromSlash(escapedPath), "@v", escapedVersion+".mod") + if err := os.MkdirAll(filepath.Dir(goMod), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(goMod, []byte("module "+modPath+"\n\ngo 1.25\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + if gox != "" { + if err := os.WriteFile(filepath.Join(dir, "gox.mod"), []byte(gox), 0644); err != nil { + t.Fatal(err) + } + } + return dir, goMod +} + +func TestResolvedModuleEqual(t *testing.T) { + replacement := ModuleRef{Path: "/src/mod", Dir: "/src/mod", GoMod: "/src/mod/go.mod"} + module := ResolvedModule{ + Selected: ModuleRef{Path: "example.com/mod", Version: "v1.2.3"}, + Replace: &replacement, + } + copy := module + copy.Replace = &ModuleRef{Path: replacement.Path, Dir: replacement.Dir, GoMod: replacement.GoMod} + if !module.Equal(copy) { + t.Fatal("identical resolved modules are not equal") + } + copy.Replace.Version = "v1.2.4" + if module.Equal(copy) { + t.Fatal("different replacements are equal") + } + copy = module + copy.Replace = nil + if module.Equal(copy) { + t.Fatal("replacement and non-replacement modules are equal") + } +} diff --git a/xgomod/resolved_validation.go b/xgomod/resolved_validation.go new file mode 100644 index 0000000..44a2b87 --- /dev/null +++ b/xgomod/resolved_validation.go @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xgomod + +import ( + "fmt" + "path/filepath" + + "golang.org/x/mod/module" +) + +func validateModulePath(path string) error { + if path == "" { + return fmt.Errorf("module path is empty") + } + if err := module.CheckPath(path); err != nil { + return fmt.Errorf("invalid module path %q: %w", path, err) + } + return nil +} + +func validateVersion(path, version string) error { + if version == "" { + return nil + } + canonical := module.CanonicalVersion(version) + if canonical == "" || canonical != version { + return fmt.Errorf("invalid non-canonical version %q for %s", version, path) + } + if err := module.Check(path, version); err != nil { + return fmt.Errorf("invalid module version %q for %s: %w", version, path, err) + } + return nil +} + +func validateResolvedModule(m ResolvedModule) error { + if err := validateResolvedModuleSyntax(m); err != nil { + return err + } + if m.Replace == nil { + return validateSourceFiles(m.Selected, "selected") + } + return validateSourceFiles(*m.Replace, "replacement") +} + +func validateResolvedModuleSyntax(m ResolvedModule) error { + if err := validateModulePath(m.Selected.Path); err != nil { + return fmt.Errorf("selected: %w", err) + } + if err := validateVersion(m.Selected.Path, m.Selected.Version); err != nil { + return fmt.Errorf("selected: %w", err) + } + if m.Main { + if m.Selected.Version != "" { + return fmt.Errorf("main module selected version must be empty") + } + if m.Replace != nil { + return fmt.Errorf("main module cannot have a replacement") + } + } else if m.Selected.Version == "" { + return fmt.Errorf("non-main module selected version must not be empty") + } + if m.Replace == nil { + return validateSourceSyntax(m.Selected, "selected") + } + if m.Selected.Dir != "" || m.Selected.GoMod != "" { + return fmt.Errorf("selected Dir/GoMod must be empty when replacement is present") + } + if m.Replace.Path == "" { + return fmt.Errorf("replacement path is empty") + } + if m.Replace.Version == "" { + if !isAbsoluteCleanPath(m.Replace.Path) { + return fmt.Errorf("local replacement.Path must be an absolute clean path: %q", m.Replace.Path) + } + if m.Replace.Dir != m.Replace.Path { + return fmt.Errorf("local replacement.Path and replacement.Dir must identify the same canonical directory") + } + } else if filepath.IsAbs(m.Replace.Path) { + return fmt.Errorf("versioned replacement.Path must be a module path: %q", m.Replace.Path) + } else { + if err := validateModulePath(m.Replace.Path); err != nil { + return fmt.Errorf("replacement: %w", err) + } + } + if err := validateVersion(m.Replace.Path, m.Replace.Version); err != nil { + return fmt.Errorf("replacement: %w", err) + } + return validateSourceSyntax(*m.Replace, "replacement") +}