From a4fcb13cc6bce3ca5e9c6bde9738f9557a3b69b1 Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Mon, 17 Aug 2026 15:50:53 +0800 Subject: [PATCH 1/5] feat(runtimeprotocol): define provider v1 and resolved class graphs Add validated runtime metadata, deterministic provider argv encoding, and provenance-bearing resolved module identities. Load class projects from the effective Go graph without flattening replacements. --- modfile/rule.go | 58 ++- modfile/rule_test.go | 71 ++++ modload/module.go | 65 ++- modload/module_test.go | 89 +++++ runtimeprotocol/argv.go | 318 +++++++++++++++ runtimeprotocol/protocol.go | 320 +++++++++++++++ runtimeprotocol/protocol_test.go | 281 +++++++++++++ xgomod/classfile.go | 193 ++++++++- xgomod/module.go | 1 + xgomod/resolved.go | 447 +++++++++++++++++++++ xgomod/resolved_test.go | 665 +++++++++++++++++++++++++++++++ 11 files changed, 2497 insertions(+), 11 deletions(-) create mode 100644 runtimeprotocol/argv.go create mode 100644 runtimeprotocol/protocol.go create mode 100644 runtimeprotocol/protocol_test.go create mode 100644 xgomod/resolved.go create mode 100644 xgomod/resolved_test.go diff --git a/modfile/rule.go b/modfile/rule.go index 1f032a5..3886db2 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,16 @@ type Pack struct { Syntax *Line } +// Runtime declares the runtime provider for a project. +// +// Protocol is the provider protocol generation (for example, "v1"). It is +// deliberately independent from the provider package's module version. +type Runtime 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 +102,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) + Runtime *Runtime // runtime provider (at most one per project) // AutoLambdas maps command => number of parameters before auto lambda. // See https://github.com/goplus/xgo/issues/2828. @@ -176,6 +188,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 == "runtime" && 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 +406,43 @@ usage: class [-embed -prefix=Prefix] *.workExt WorkClass [WorkPrototype]`, sw) return } proj.Pack = &Pack{Directory: dir, IndexFile: indexFile, Syntax: line} + case "runtime": + if line.InBlock { + errorf("runtime directive must not be a block") + return + } + proj := f.proj() + if proj == nil { + errorf("runtime must declare after a project definition") + return + } + if proj.Runtime != nil { + errorf("duplicate runtime directive in the same project") + return + } + if len(args) != 2 { + errorf("usage: runtime ") + return + } + protocol, err := parseString(&args[0]) + if err != nil { + wrapError(err) + return + } + if !runtimeProtocolRE.MatchString(protocol) { + errorf("runtime 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("runtime package %q is not a valid import path: %v", pkgPath, err) + return + } + proj.Runtime = &Runtime{Protocol: protocol, Package: pkgPath, Syntax: line} case "autolambda": proj := f.proj() if proj == nil { @@ -485,8 +538,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+`) + runtimeProtocolRE = 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..e1ba817 100644 --- a/modfile/rule_test.go +++ b/modfile/rule_test.go @@ -16,6 +16,7 @@ package modfile import ( + "strings" "syscall" "testing" ) @@ -157,6 +158,76 @@ func TestParsePack(t *testing.T) { } } +func TestParseRuntime(t *testing.T) { + const src = ` +xgo 1.6 + +project main.foo Game example.com/framework math +runtime v1 example.com/framework/cmd/runtime // provider +` + f, err := ParseLax("gox.mod", []byte(src), nil) + if err != nil { + t.Fatal("ParseLax failed:", err) + } + proj := f.proj() + if proj == nil || proj.Runtime == nil { + t.Fatal("expected runtime") + } + if proj.Runtime.Protocol != "v1" || proj.Runtime.Package != "example.com/framework/cmd/runtime" { + t.Fatalf("runtime = %#v", proj.Runtime) + } + 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().Runtime; got == nil || got.Protocol != "v1" || got.Package != proj.Runtime.Package { + t.Fatalf("round-trip runtime = %#v", got) + } +} + +func TestParseRuntimeErrors(t *testing.T) { + tests := []struct { + name string + want string + src string + }{ + {"before project", "runtime must declare after a project definition", "runtime v1 example.com/provider"}, + {"wrong arity", "usage: runtime ", "project example.com/app\nruntime v1"}, + {"invalid protocol", "runtime protocol must match v[1-9][0-9]*", "project example.com/app\nruntime 1 example.com/provider"}, + {"zero protocol", "runtime protocol must match v[1-9][0-9]*", "project example.com/app\nruntime v0 example.com/provider"}, + {"invalid package", "runtime package", "project example.com/app\nruntime v1 ../provider"}, + {"duplicate", "duplicate runtime directive in the same project", "project example.com/app\nruntime v1 example.com/provider\nruntime v1 example.com/provider"}, + } + 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 TestParseRuntimeIsNotABlockDirective(t *testing.T) { + _, err := ParseLax("gox.mod", []byte(`project example.com/app +runtime ( +v1 example.com/provider +)`), nil) + if err == nil || !strings.Contains(err.Error(), "runtime directive must not be a block") { + t.Fatalf("error = %v", err) + } + _, err = ParseLax("gox.mod", []byte("project example.com/app\nruntime (\n)\n"), nil) + if err == nil || !strings.Contains(err.Error(), "runtime 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..2b15eaf 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,26 @@ var ( type Module struct { *gomodfile.File - Opt *modfile.File + Opt *modfile.File + goModIdentity FileIdentity + goxModIdentity FileIdentity +} + +// FileIdentity binds a module file path to the exact bytes read by LoadFrom. +// The zero value means that no file snapshot was loaded. +type FileIdentity struct { + Path string + SHA256 string +} + +// GoModIdentity returns the exact go.mod snapshot used to parse File. +func (p Module) GoModIdentity() FileIdentity { + return p.goModIdentity +} + +// GoxModIdentity returns the exact 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 +164,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 +216,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 +237,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 +248,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 +263,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 +328,33 @@ 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 whether comments contain an xgo:class or gop:class +// marker. A marker must end at a token boundary; optional payload must be +// separated from the marker by whitespace. +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/runtimeprotocol/argv.go b/runtimeprotocol/argv.go new file mode 100644 index 0000000..5fff2d5 --- /dev/null +++ b/runtimeprotocol/argv.go @@ -0,0 +1,318 @@ +/* + * 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 runtimeprotocol + +import ( + "fmt" + "strings" + + "github.com/goplus/mod/xgomod" +) + +var singularOptions = map[string]struct{}{ + "project-dir": {}, + "project-file": {}, + "module-root": {}, + "provider-package": {}, + "selected-path": {}, + "selected-version": {}, + "origin-main": {}, + "selected-dir": {}, + "selected-gomod": {}, + "replace-path": {}, + "replace-version": {}, + "replace-dir": {}, + "replace-gomod": {}, + "project-ext": {}, + "project-full-ext": {}, + "pack-dir": {}, + "pack-index": {}, + "declaration-file": {}, + "declaration-sha256": {}, + "go-command": {}, + "graph-work-dir": {}, + "go-work": {}, + "output": {}, + "final-output": {}, +} + +var commonRequiredOptions = []string{ + "project-dir", + "project-file", + "module-root", + "provider-package", + "selected-path", + "selected-version", + "origin-main", + "project-ext", + "project-full-ext", + "declaration-file", + "declaration-sha256", + "go-command", + "graph-work-dir", + "go-work", +} + +var replacementOptions = []string{ + "replace-path", + "replace-version", + "replace-dir", + "replace-gomod", +} + +type rawOptions struct { + values map[string]string + graphFlags []string + buildFlags []string +} + +// Encode returns the deterministic argv following the provider 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("provider-package", request.ProviderPackage), + option("selected-path", request.ProviderOrigin.Selected.Path), + option("selected-version", request.ProviderOrigin.Selected.Version), + option("origin-main", fmt.Sprint(request.ProviderOrigin.Main)), + } + if request.ProviderOrigin.Replace == nil { + args = append(args, + option("selected-dir", request.ProviderOrigin.Selected.Dir), + option("selected-gomod", request.ProviderOrigin.Selected.GoMod), + ) + } else { + replacement := request.ProviderOrigin.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 the complete provider argv following argv[0]. Unknown, +// duplicate, partial, and action-inapplicable fields fail closed. +func Parse(args []string) (Request, error) { + var request Request + if len(args) < 2 { + return request, fmt.Errorf("runtimeprotocol: request requires preamble and action") + } + if args[0] != PreambleV1 { + return request, fmt.Errorf("runtimeprotocol: unsupported preamble %q", args[0]) + } + request.Version = Version1 + request.Action = Action(args[1]) + if request.Action != ActionRun && request.Action != ActionBuild { + return Request{}, fmt.Errorf("runtimeprotocol: unsupported action %q", args[1]) + } + + 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("runtimeprotocol: 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("runtimeprotocol: build does not accept -- or positional arguments") + } + } + } + + raw, err := parseOptions(optionArgs) + if err != nil { + return Request{}, err + } + for _, name := range commonRequiredOptions { + if _, ok := raw.values[name]; !ok { + return Request{}, fmt.Errorf("runtimeprotocol: option --%s is required", 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"], + } + _, hasPackDir := raw.values["pack-dir"] + _, hasPackIndex := raw.values["pack-index"] + if hasPackDir != hasPackIndex { + return Request{}, fmt.Errorf("runtimeprotocol: pack options must be supplied as a complete group") + } + if hasPackDir { + request.Project.Pack = &Pack{Directory: raw.values["pack-dir"], IndexFile: raw.values["pack-index"]} + } + + request.ProviderPackage = raw.values["provider-package"] + request.ProviderOrigin = xgomod.ResolvedModule{ + Selected: xgomod.ModuleRef{ + Path: raw.values["selected-path"], + Version: raw.values["selected-version"], + }, + } + switch raw.values["origin-main"] { + case "true": + request.ProviderOrigin.Main = true + case "false": + default: + return Request{}, fmt.Errorf("runtimeprotocol: invalid --origin-main %q: expected true or false", raw.values["origin-main"]) + } + if err := parseModuleSource(&request.ProviderOrigin, raw); err != nil { + return Request{}, err + } + + 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...) + if request.Action == ActionBuild { + output, hasOutput := raw.values["output"] + final, hasFinal := raw.values["final-output"] + if !hasOutput || !hasFinal { + return Request{}, fmt.Errorf("runtimeprotocol: build requires --output and --final-output") + } + request.Output = &BuildOutput{Staging: output, Final: final} + } else if _, ok := raw.values["output"]; ok { + return Request{}, fmt.Errorf("runtimeprotocol: run does not accept --output") + } else if _, ok := raw.values["final-output"]; ok { + return Request{}, fmt.Errorf("runtimeprotocol: run does not accept --final-output") + } + if err := request.Validate(); err != nil { + return Request{}, err + } + return request, nil +} + +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("runtimeprotocol: unexpected positional argument %q", arg) + } + name, value, ok := strings.Cut(strings.TrimPrefix(arg, "--"), "=") + if !ok || name == "" { + return rawOptions{}, fmt.Errorf("runtimeprotocol: 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 _, ok := singularOptions[name]; !ok { + return rawOptions{}, fmt.Errorf("runtimeprotocol: unknown option --%s", name) + } + if _, duplicate := raw.values[name]; duplicate { + return rawOptions{}, fmt.Errorf("runtimeprotocol: option --%s may not be repeated", name) + } + raw.values[name] = value + } + } + return raw, nil +} + +func parseModuleSource(origin *xgomod.ResolvedModule, raw rawOptions) error { + replacementCount := 0 + for _, name := range replacementOptions { + if _, ok := raw.values[name]; ok { + replacementCount++ + } + } + _, selectedDir := raw.values["selected-dir"] + _, selectedGoMod := raw.values["selected-gomod"] + switch replacementCount { + case 0: + if !selectedDir || !selectedGoMod { + return fmt.Errorf("runtimeprotocol: origin without replacement requires --selected-dir and --selected-gomod") + } + origin.Selected.Dir = raw.values["selected-dir"] + origin.Selected.GoMod = raw.values["selected-gomod"] + case len(replacementOptions): + if selectedDir || selectedGoMod { + return fmt.Errorf("runtimeprotocol: origin with replacement forbids --selected-dir and --selected-gomod") + } + origin.Replace = &xgomod.ModuleRef{ + Path: raw.values["replace-path"], + Version: raw.values["replace-version"], + Dir: raw.values["replace-dir"], + GoMod: raw.values["replace-gomod"], + } + default: + return fmt.Errorf("runtimeprotocol: replacement options must be supplied as a complete group") + } + return nil +} + +func option(name, value string) string { return "--" + name + "=" + value } diff --git a/runtimeprotocol/protocol.go b/runtimeprotocol/protocol.go new file mode 100644 index 0000000..f4e51da --- /dev/null +++ b/runtimeprotocol/protocol.go @@ -0,0 +1,320 @@ +/* + * 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 runtimeprotocol defines the transport-neutral request model and the +// argv encoding used by XGo runtime providers. +// +// Validation in this package is deliberately structural: it validates the +// protocol shape, portable path spelling, module identities, and the bounded +// flag vocabulary without reading the filesystem. A provider must separately +// pin and verify every identity-bearing path before using it. +package runtimeprotocol + +import ( + "encoding/hex" + "fmt" + "path" + "path/filepath" + "strings" + + "github.com/goplus/mod/xgomod" + "golang.org/x/mod/module" +) + +const ( + // Version1 is the value used by the gox.mod runtime directive. + Version1 = "v1" + // PreambleV1 is the first argv element passed to a v1 provider. + PreambleV1 = "xgo-runtime-v1" +) + +// Action identifies the operation requested from a provider. Install uses a +// transactional build request whose final output is selected by XGo. +type Action string + +const ( + ActionRun Action = "run" + ActionBuild Action = "build" +) + +// Pack describes optional project pack metadata. It is intentionally +// provider-neutral: a runtime that does not use a pack receives nil. +type Pack struct { + Directory string + IndexFile string +} + +// Project is the immutable project snapshot discovered by XGo. +type Project struct { + Dir string + File string + ModuleRoot string + Extension string + FullExtension string + Pack *Pack +} + +// Graph carries the exact Go command/workspace policy used for discovery. +type Graph struct { + GoCommand string + WorkDir string + GoWork string + Flags []string +} + +// BuildOutput contains XGo's private staging path and user-visible final path. +type BuildOutput struct { + Staging string + Final string +} + +// Request is one complete provider request. Run requires Output == nil and +// preserves ApplicationArgs verbatim. Build requires Output != nil and no +// application arguments. +type Request struct { + Version string + Action Action + Project Project + ProviderPackage string + ProviderOrigin xgomod.ResolvedModule + Declaration xgomod.FileIdentity + Graph Graph + BuildFlags []string + Output *BuildOutput + ApplicationArgs []string +} + +// Validate checks the v1 request without consulting ambient filesystem state. +func (r Request) Validate() error { + if r.Version != Version1 { + return fmt.Errorf("runtimeprotocol: unsupported version %q", r.Version) + } + if r.Action != ActionRun && r.Action != ActionBuild { + return fmt.Errorf("runtimeprotocol: unsupported action %q", r.Action) + } + 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("runtimeprotocol: project-file must be a top-level file in project-dir") + } + if !pathWithin(r.Project.ModuleRoot, r.Project.Dir) { + return fmt.Errorf("runtimeprotocol: project-dir must be within module-root") + } + if r.Project.Extension == "" || strings.IndexByte(r.Project.Extension, 0) >= 0 { + return fmt.Errorf("runtimeprotocol: project extension may not be empty or contain NUL") + } + if r.Project.FullExtension == "" || strings.IndexByte(r.Project.FullExtension, 0) >= 0 { + return fmt.Errorf("runtimeprotocol: 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 := validateProviderOrigin(r.ProviderOrigin); err != nil { + return fmt.Errorf("runtimeprotocol: provider origin: %w", err) + } + if err := validateSHA256("declaration-sha256", r.Declaration.SHA256); err != nil { + return err + } + effective := r.ProviderOrigin.Effective() + declarationBase := filepath.Base(r.Declaration.Path) + if filepath.Dir(r.Declaration.Path) != effective.Dir || (declarationBase != "gox.mod" && declarationBase != "gop.mod") { + return fmt.Errorf("runtimeprotocol: declaration-file must be provider metadata (gox.mod or gop.mod) in %q", effective.Dir) + } + if err := module.CheckImportPath(r.ProviderPackage); err != nil { + return fmt.Errorf("runtimeprotocol: invalid provider package %q: %w", r.ProviderPackage, err) + } + if !moduleContainsPackage(r.ProviderOrigin.Selected.Path, r.ProviderPackage) { + return fmt.Errorf("runtimeprotocol: provider package %q is outside selected module %q", r.ProviderPackage, r.ProviderOrigin.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("runtimeprotocol: application argument contains NUL") + } + } + switch r.Action { + case ActionRun: + if r.Output != nil { + return fmt.Errorf("runtimeprotocol: run request cannot contain output paths") + } + case ActionBuild: + if r.Output == nil { + return fmt.Errorf("runtimeprotocol: build request requires output paths") + } + if len(r.ApplicationArgs) != 0 { + return fmt.Errorf("runtimeprotocol: 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("runtimeprotocol: output and final-output must be different paths") + } + } + return nil +} + +func validateProviderOrigin(origin xgomod.ResolvedModule) error { + return origin.ValidateSyntax() +} + +func validateAbsolutePath(name, value string) error { + if value == "" || strings.IndexByte(value, 0) >= 0 { + return fmt.Errorf("runtimeprotocol: path --%s may not be empty or contain NUL", name) + } + if !filepath.IsAbs(value) { + return fmt.Errorf("runtimeprotocol: path --%s must be absolute: %q", name, value) + } + if filepath.Clean(value) != value { + return fmt.Errorf("runtimeprotocol: 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("runtimeprotocol: pack directory must be a clean non-empty relative slash path: %q", value) + } + if value == ".." || strings.HasPrefix(value, "../") { + return fmt.Errorf("runtimeprotocol: 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("runtimeprotocol: 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("runtimeprotocol: --%s must contain 64 hexadecimal characters", name) + } + if _, err := hex.DecodeString(value); err != nil { + return fmt.Errorf("runtimeprotocol: --%s is not a SHA-256 digest: %w", name, err) + } + if value != strings.ToLower(value) { + return fmt.Errorf("runtimeprotocol: --%s must use lowercase hexadecimal", name) + } + return nil +} + +func validateGraphFlags(flags []string) error { + seen := make(map[string]struct{}, len(flags)) + for _, flag := range flags { + name, value, ok := splitCanonicalFlag(flag) + if !ok { + return fmt.Errorf("runtimeprotocol: graph flag %q must use -name=value", flag) + } + if _, duplicate := seen[name]; duplicate { + return fmt.Errorf("runtimeprotocol: graph flag -%s may not be repeated", name) + } + seen[name] = struct{}{} + switch name { + case "mod": + if value != "mod" && value != "readonly" && value != "vendor" { + return fmt.Errorf("runtimeprotocol: 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("runtimeprotocol: graph flag -%s is not supported", name) + } + } + return nil +} + +func validateBuildFlags(flags []string) error { + seen := make(map[string]struct{}, len(flags)) + for _, flag := range flags { + name, value, ok := splitCanonicalFlag(flag) + if !ok { + return fmt.Errorf("runtimeprotocol: build flag %q must use -name=value", flag) + } + if _, duplicate := seen[name]; duplicate { + return fmt.Errorf("runtimeprotocol: build flag -%s may not be repeated", name) + } + seen[name] = struct{}{} + switch name { + case "v", "x", "work", "trimpath": + if value != "true" { + return fmt.Errorf("runtimeprotocol: build flag -%s has unsupported value %q", name, value) + } + case "buildvcs": + if value != "false" { + return fmt.Errorf("runtimeprotocol: build flag -buildvcs has unsupported value %q", value) + } + default: + return fmt.Errorf("runtimeprotocol: build flag -%s is not supported", name) + } + } + 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/runtimeprotocol/protocol_test.go b/runtimeprotocol/protocol_test.go new file mode 100644 index 0000000..7823261 --- /dev/null +++ b/runtimeprotocol/protocol_test.go @@ -0,0 +1,281 @@ +/* + * 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 runtimeprotocol + +import ( + "reflect" + "strings" + "testing" + + "github.com/goplus/mod/xgomod" +) + +func testRequest() Request { + return Request{ + Version: Version1, + Action: ActionRun, + Project: Project{ + Dir: "/workspace/app/game", + File: "/workspace/app/game/main.foo", + ModuleRoot: "/workspace/app", + Extension: ".foo", + FullExtension: "*.foo", + Pack: &Pack{Directory: "payload", IndexFile: "index.data"}, + }, + ProviderPackage: "example.test/framework/cmd/provider", + ProviderOrigin: xgomod.ResolvedModule{ + Selected: xgomod.ModuleRef{Path: "example.test/framework", Version: "v1.2.3"}, + Replace: &xgomod.ModuleRef{Path: "/workspace/framework", Dir: "/workspace/framework", GoMod: "/workspace/framework/go.mod"}, + }, + Declaration: xgomod.FileIdentity{Path: "/workspace/framework/gox.mod", SHA256: strings.Repeat("a", 64)}, + Graph: Graph{GoCommand: "/usr/bin/go", WorkDir: "/workspace/app", GoWork: "off", Flags: []string{"-mod=readonly", "-modfile=/workspace/app/alt.mod"}}, + BuildFlags: []string{"-v=true", "-trimpath=true", "-buildvcs=false"}, + ApplicationArgs: []string{"", "a b", "--"}, + } +} + +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=/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.ProviderOrigin = xgomod.ResolvedModule{ + Selected: xgomod.ModuleRef{ + Path: "example.test/framework", Version: "v1.2.3", + Dir: "/workspace/framework", GoMod: "/workspace/framework/go.mod", + }, + } + want.Output = &BuildOutput{Staging: "/workspace/out/.game.tmp", Final: "/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: "/workspace/framework", GoMod: "/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: "/workspace/framework-fork", GoMod: "/workspace/framework-fork/go.mod", + }, + }, + } + for name, origin := range tests { + t.Run(name, func(t *testing.T) { + want := testRequest() + want.ProviderOrigin = origin + want.Declaration.Path = origin.Effective().Dir + "/gox.mod" + want.Graph.GoWork = "/workspace/go.work" + want.Graph.Flags = append(want.Graph.Flags, "-overlay=/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 = "/does/not/exist/game" + request.Project.File = "/does/not/exist/game/main.foo" + request.Project.ModuleRoot = "/does/not/exist" + request.Declaration.Path = "/does/not/exist/framework/gox.mod" + request.ProviderOrigin.Replace.Path = "/does/not/exist/framework" + request.ProviderOrigin.Replace.Dir = "/does/not/exist/framework" + request.ProviderOrigin.Replace.GoMod = "/does/not/exist/framework/go.mod" + if err := request.Validate(); err != nil { + t.Fatalf("structural validation consulted ambient filesystem: %v", err) + } +} + +func TestPackDotIsProviderNeutral(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 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 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: "/tmp/a", Final: "/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"} }, + "provider outside module": func(r *Request) { r.ProviderPackage = "example.test/other/cmd/provider" }, + "flattened replacement": func(r *Request) { r.ProviderOrigin.Selected.Dir = "/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 provider": func(r *Request) { + r.Declaration.Path = "/workspace/other/gox.mod" + }, + "main origin with version": func(r *Request) { + r.ProviderOrigin = xgomod.ResolvedModule{ + Selected: xgomod.ModuleRef{Path: "example.test/framework", Version: "v1.2.3", Dir: "/workspace/framework", GoMod: "/workspace/framework/go.mod"}, Main: true, + } + }, + "local replace with module path": func(r *Request) { + r.ProviderOrigin.Replace.Path = "example.test/framework-fork" + }, + "local replace identity mismatch": func(r *Request) { + r.ProviderOrigin.Replace.Path = "/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") + } + }) + } +} + +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) + } +} + +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 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/xgomod/classfile.go b/xgomod/classfile.go index 4d6f0f0..45e9c6f 100644 --- a/xgomod/classfile.go +++ b/xgomod/classfile.go @@ -17,6 +17,11 @@ package xgomod import ( + "fmt" + "os" + "path/filepath" + "strings" + "github.com/goplus/mod" "github.com/goplus/mod/modcache" "github.com/goplus/mod/modfetch" @@ -89,6 +94,22 @@ func (p *Module) LookupClass(ext string) (c *Project, ok bool) { return } +// LookupClassInfo looks up a classfile and returns its declaring project and +// resolved module provenance. Built-in projects have nil Origin and an empty +// RequiredXGo. +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 { + // Modules loaded through the legacy API predate provenance. Keep the + // lookup useful without manufacturing an origin that could be mistaken + // for a resolved graph record. + return &ProjectInfo{Project: project}, true + } + return nil, false +} + // ImportClasses imports all classfiles found in this module (from go.mod/gox.mod). func (p *Module) ImportClasses(importClass ...func(c *Project)) (err error) { var impcls func(c *Project) @@ -96,6 +117,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 @@ -110,6 +132,167 @@ func (p *Module) ImportClasses(importClass ...func(c *Project)) (err error) { return } +// ImportClassesResolved imports class metadata from an already-resolved +// module/workspace graph. The graph is validated before the receiver is +// changed. In particular, class modules come only from graph.ClassModules; the +// receiver's legacy Opt.ClassMods is never consulted by this method. +func (p *Module) ImportClassesResolved(graph ResolvedClassGraph, importClass ...func(*ProjectInfo)) 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) + callbacks := make([]*ProjectInfo, 0) + register := func(info *ProjectInfo) error { + if err := registerProject(projects, infos, info); err != nil { + return err + } + if importClass != nil { + callbacks = append(callbacks, info) + } + return nil + } + // Built-ins are deliberately provenance-free and cannot declare a runtime. + 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 + if importClass != nil { + for _, info := range callbacks { + importClass[0](info) + } + } + 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) + } + rel, err := filepath.Rel(targetRoot, path) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { + return "", "", fmt.Errorf("receiver target gox.mod is outside graph target source") + } + digest, err = fileSHA256(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.Runtime != nil && info.Origin == nil { + return fmt.Errorf("runtime 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.Runtime != nil || info.Project.Runtime != nil { + return fmt.Errorf("runtime 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 +} + func (p *Module) importMod(modPath string, imcls func(c *Project)) (err error) { mod, ok := p.LookupDepMod(modPath) if !ok { @@ -146,9 +329,13 @@ 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} + if p.infos == nil { + p.infos = make(map[string]*ProjectInfo) + } + for _, ext := range projectExts(c) { + p.projs[ext] = c + p.infos[ext] = info } if impcls != nil { impcls(c) 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/resolved.go b/xgomod/resolved.go new file mode 100644 index 0000000..fd3d13c --- /dev/null +++ b/xgomod/resolved.go @@ -0,0 +1,447 @@ +/* + * 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" + "path/filepath" + "strings" + + "github.com/goplus/mod/modfile" + "github.com/goplus/mod/modload" + gomodfile "golang.org/x/mod/modfile" + "golang.org/x/mod/module" +) + +// ModuleRef identifies one logical module selection or its effective source. +// Dir and GoMod are populated only for an effective source record. In +// particular, a selected module with a replacement must not copy the +// replacement's physical paths into Selected. +type ModuleRef struct { + Path string + Version string + Dir string + GoMod string +} + +// ResolvedModule keeps the logical module selected by the graph separate from +// an optional replacement source. +type ResolvedModule struct { + Selected ModuleRef + Replace *ModuleRef + Main bool +} + +// Effective returns the source selected for reading files and metadata. +func (m ResolvedModule) Effective() ModuleRef { + if m.Replace != nil { + return *m.Replace + } + return m.Selected +} + +// IsLocal reports whether the module is supplied by the main module or by a +// filesystem replacement. A replacement with a release version is still a +// replacement source, not a local module. +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 the resolved module's logical identity and path +// spelling without reading the filesystem. Transport decoders use this to +// reject malformed provenance; consumers must call Validate before using the +// effective source. +func (m ResolvedModule) ValidateSyntax() error { + return validateResolvedModuleSyntax(m) +} + +// ResolvedClassGraph is the already-resolved module/workspace graph supplied by +// XGo. xgomod validates and consumes this snapshot; it never invokes the Go +// command to discover another graph. +type ResolvedClassGraph struct { + Target ResolvedModule + ClassModules []ResolvedModule + TargetModFile FileIdentity +} + +// FileIdentity binds metadata to the exact bytes parsed by the caller. +type FileIdentity = modload.FileIdentity + +// ProjectInfo is class metadata together with the module that declared it. +// Built-in GshProject and TestProject entries have no Origin and no required +// XGo version. +type ProjectInfo struct { + Project *modfile.Project + Origin *ResolvedModule + Declaration FileIdentity + RequiredXGo string +} + +func cloneResolvedModule(m ResolvedModule) *ResolvedModule { + c := m + if m.Replace != nil { + r := *m.Replace + c.Replace = &r + } + return &c +} + +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 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 validateSource(ref ModuleRef, label string) error { + if err := validateSourceSyntax(ref, label); err != nil { + return err + } + canonDir, err := canonicalPath(ref.Dir, true) + if err != nil { + return fmt.Errorf("%s.Dir: %w", label, err) + } + if filepath.Clean(ref.Dir) != canonDir { + return fmt.Errorf("%s.Dir must be canonical: %q", label, ref.Dir) + } + canonGoMod, err := canonicalPath(ref.GoMod, false) + if err != nil { + return fmt.Errorf("%s.GoMod: %w", label, err) + } + if filepath.Clean(ref.GoMod) != canonGoMod { + return fmt.Errorf("%s.GoMod must be canonical: %q", label, ref.GoMod) + } + rel, err := filepath.Rel(canonDir, canonGoMod) + if err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) { + 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 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 !filepath.IsAbs(item.value) || filepath.Clean(item.value) != item.value || strings.IndexByte(item.value, 0) >= 0 { + return fmt.Errorf("%s.%s must be an absolute clean path: %q", label, item.field, item.value) + } + } + return nil +} + +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 +} + +func validateResolvedModule(m ResolvedModule) error { + if err := validateResolvedModuleSyntax(m); err != nil { + return err + } + if m.Replace == nil { + return validateSource(m.Selected, "selected") + } + if filepath.IsAbs(m.Replace.Path) { + canonPath, err := canonicalPath(m.Replace.Path, true) + if err != nil { + return fmt.Errorf("replacement.Path: %w", err) + } + if m.Replace.Path != canonPath { + return fmt.Errorf("replacement.Path must be canonical: %q", m.Replace.Path) + } + } + return validateSource(*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 !filepath.IsAbs(m.Replace.Path) || filepath.Clean(m.Replace.Path) != m.Replace.Path || strings.IndexByte(m.Replace.Path, 0) >= 0 { + 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 filepath.IsAbs(m.Replace.Path) { + if filepath.Clean(m.Replace.Path) != m.Replace.Path || strings.IndexByte(m.Replace.Path, 0) >= 0 { + return fmt.Errorf("replacement.Path must be an absolute clean path: %q", m.Replace.Path) + } + } + if err := validateVersion(m.Replace.Path, m.Replace.Version); err != nil { + return fmt.Errorf("replacement: %w", err) + } + return validateSourceSyntax(*m.Replace, "replacement") +} + +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) + } + path, err := canonicalPath(identity.Path, false) + if err != nil { + return nil, fmt.Errorf("target modfile path: %w", err) + } + if filepath.Clean(identity.Path) != path { + return nil, fmt.Errorf("target modfile path must be canonical: %q", identity.Path) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read target modfile: %w", err) + } + sum := sha256.Sum256(data) + got := hex.EncodeToString(sum[:]) + if !strings.EqualFold(got, identity.SHA256) { + return nil, fmt.Errorf("target modfile SHA-256 mismatch for %s", identity.Path) + } + return data, nil +} + +func fileSHA256(path string) (string, error) { + b, err := os.ReadFile(path) + if err != nil { + return "", err + } + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]), nil +} + +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)) + } + seenModules := make(map[string]struct{}, len(g.ClassModules)) + for i, mod := range g.ClassModules { + path := mod.Selected.Path + if path == g.Target.Selected.Path { + return fmt.Errorf("target module %q is repeated in ClassModules", path) + } + if _, ok := seenModules[path]; ok { + return fmt.Errorf("duplicate resolved class module %q", path) + } + seenModules[path] = struct{}{} + if err := validateResolvedModule(mod); err != nil { + return fmt.Errorf("class module %q: %w", path, err) + } + if path != markerPaths[i] { + return fmt.Errorf("class module %d has logical path %q, want marker %q", i, path, markerPaths[i]) + } + } + 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 +} + +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 +} diff --git a/xgomod/resolved_test.go b/xgomod/resolved_test.go new file mode 100644 index 0000000..428bddf --- /dev/null +++ b/xgomod/resolved_test.go @@ -0,0 +1,665 @@ +/* + * 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" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/goplus/mod/modload" + "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 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 TestResolvedModuleEffectiveAndLocal(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("filesystem replacement should be local") + } + resolved.Replace.Version = "v1.2.3" + if resolved.IsLocal() { + t.Fatal("versioned replacement should not be local") + } + main := ResolvedModule{Selected: selected, Main: true} + if !main.IsLocal() { + t.Fatal("main module should be 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 TestImportClassesResolvedProvenanceAndSelfOverlap(t *testing.T) { + root := t.TempDir() + targetGox := `xgo 1.9 + +project .foo Game example.com/app +class .foo Sprite + runtime v1 example.com/app/cmd/runtime +` + 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), + } + var callbacks []*ProjectInfo + if err := m.ImportClassesResolved(graph, func(info *ProjectInfo) { callbacks = append(callbacks, info) }); err != nil { + t.Fatal(err) + } + targetInfo, ok := m.LookupClassInfo(".foo") + if !ok || targetInfo.Project.Runtime == 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) + } + if len(callbacks) != 4 { // Test, Gsh, target and dependency projects. + t.Fatalf("callback count = %d", len(callbacks)) + } +} + +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 .second Second example.com/second\n") + firstDir := filepath.Join(root, "first") + firstGoMod := writeModule(t, firstDir, "example.com/first", "xgo 1.9\nproject .first 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), + } + var imported []string + if err := New(loaded).ImportClassesResolved(graph, func(info *ProjectInfo) { + if info.Origin != nil && info.Origin.Selected.Path != target.Selected.Path { + imported = append(imported, info.Origin.Selected.Path) + } + }); err != nil { + t.Fatal(err) + } + if got, want := strings.Join(imported, ","), "example.com/second,example.com/first"; got != want { + t.Fatalf("import order = %q, want %q", got, want) + } +} + +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) + } +} + +func TestImportClassesResolvedRuntimeCollision(t *testing.T) { + root := t.TempDir() + targetGox := "xgo 1.9\nproject .foo Game example.com/app\nruntime v1 example.com/app/runtime\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\nruntime v1 example.com/class/runtime\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(), "runtime 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 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}, "duplicate resolved class module"}, + {"target repeated", []ResolvedModule{target, second}, "repeated in ClassModules"}, + {"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) + } +} From a0564e5b030029a9dc285094291959e5fad1ee7b Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Tue, 18 Aug 2026 17:03:13 +0800 Subject: [PATCH 2/5] refactor(runtime): organize protocol and graph validation Share canonical graph and build flag validation, keep resolved-module checks focused, and place cloning beside its import flow without changing error contracts. --- runtimeprotocol/protocol.go | 44 ++++++++++++++++++------------------- xgomod/resolved.go | 32 ++++++++++++++------------- 2 files changed, 39 insertions(+), 37 deletions(-) diff --git a/runtimeprotocol/protocol.go b/runtimeprotocol/protocol.go index f4e51da..daf4539 100644 --- a/runtimeprotocol/protocol.go +++ b/runtimeprotocol/protocol.go @@ -246,16 +246,7 @@ func validateSHA256(name, value string) error { } func validateGraphFlags(flags []string) error { - seen := make(map[string]struct{}, len(flags)) - for _, flag := range flags { - name, value, ok := splitCanonicalFlag(flag) - if !ok { - return fmt.Errorf("runtimeprotocol: graph flag %q must use -name=value", flag) - } - if _, duplicate := seen[name]; duplicate { - return fmt.Errorf("runtimeprotocol: graph flag -%s may not be repeated", name) - } - seen[name] = struct{}{} + return validateFlags("graph", flags, func(name, value string) error { switch name { case "mod": if value != "mod" && value != "readonly" && value != "vendor" { @@ -268,21 +259,12 @@ func validateGraphFlags(flags []string) error { default: return fmt.Errorf("runtimeprotocol: graph flag -%s is not supported", name) } - } - return nil + return nil + }) } func validateBuildFlags(flags []string) error { - seen := make(map[string]struct{}, len(flags)) - for _, flag := range flags { - name, value, ok := splitCanonicalFlag(flag) - if !ok { - return fmt.Errorf("runtimeprotocol: build flag %q must use -name=value", flag) - } - if _, duplicate := seen[name]; duplicate { - return fmt.Errorf("runtimeprotocol: build flag -%s may not be repeated", name) - } - seen[name] = struct{}{} + return validateFlags("build", flags, func(name, value string) error { switch name { case "v", "x", "work", "trimpath": if value != "true" { @@ -295,6 +277,24 @@ func validateBuildFlags(flags []string) error { default: return fmt.Errorf("runtimeprotocol: 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("runtimeprotocol: %s flag %q must use -name=value", kind, flag) + } + if _, duplicate := seen[name]; duplicate { + return fmt.Errorf("runtimeprotocol: %s flag -%s may not be repeated", kind, name) + } + seen[name] = struct{}{} + if err := validateValue(name, value); err != nil { + return err + } } return nil } diff --git a/xgomod/resolved.go b/xgomod/resolved.go index fd3d13c..bc75440 100644 --- a/xgomod/resolved.go +++ b/xgomod/resolved.go @@ -99,15 +99,6 @@ type ProjectInfo struct { RequiredXGo string } -func cloneResolvedModule(m ResolvedModule) *ResolvedModule { - c := m - if m.Replace != nil { - r := *m.Replace - c.Replace = &r - } - return &c -} - func canonicalPath(path string, wantDir bool) (string, error) { if path == "" || !filepath.IsAbs(path) { return "", fmt.Errorf("path must be absolute: %q", path) @@ -194,13 +185,17 @@ func validateSourceSyntax(ref ModuleRef, label string) error { field string value string }{{"Dir", ref.Dir}, {"GoMod", ref.GoMod}} { - if !filepath.IsAbs(item.value) || filepath.Clean(item.value) != item.value || strings.IndexByte(item.value, 0) >= 0 { + 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") @@ -295,7 +290,7 @@ func validateResolvedModuleSyntax(m ResolvedModule) error { return fmt.Errorf("replacement path is empty") } if m.Replace.Version == "" { - if !filepath.IsAbs(m.Replace.Path) || filepath.Clean(m.Replace.Path) != m.Replace.Path || strings.IndexByte(m.Replace.Path, 0) >= 0 { + 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 { @@ -308,10 +303,8 @@ func validateResolvedModuleSyntax(m ResolvedModule) error { return fmt.Errorf("replacement: %w", err) } } - if filepath.IsAbs(m.Replace.Path) { - if filepath.Clean(m.Replace.Path) != m.Replace.Path || strings.IndexByte(m.Replace.Path, 0) >= 0 { - return fmt.Errorf("replacement.Path must be an absolute clean path: %q", m.Replace.Path) - } + if filepath.IsAbs(m.Replace.Path) && !isAbsoluteCleanPath(m.Replace.Path) { + return fmt.Errorf("replacement.Path must be an absolute clean path: %q", m.Replace.Path) } if err := validateVersion(m.Replace.Path, m.Replace.Version); err != nil { return fmt.Errorf("replacement: %w", err) @@ -445,3 +438,12 @@ func importResolvedModule(ref ResolvedModule) ([]*ProjectInfo, error) { } return infos, nil } + +func cloneResolvedModule(m ResolvedModule) *ResolvedModule { + c := m + if m.Replace != nil { + r := *m.Replace + c.Replace = &r + } + return &c +} From 308b7e828835bce780963bb015515bdc9c36ab48 Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Tue, 18 Aug 2026 18:03:54 +0800 Subject: [PATCH 3/5] test(runtime): cover protocol and provenance boundaries --- modfile/rule_test.go | 2 + runtimeprotocol/protocol_test.go | 561 +++++++++++++++++++++++++++++-- xgomod/resolved_test.go | 534 +++++++++++++++++++++++++++++ 3 files changed, 1071 insertions(+), 26 deletions(-) diff --git a/modfile/rule_test.go b/modfile/rule_test.go index e1ba817..9b331b5 100644 --- a/modfile/rule_test.go +++ b/modfile/rule_test.go @@ -196,7 +196,9 @@ func TestParseRuntimeErrors(t *testing.T) { {"wrong arity", "usage: runtime ", "project example.com/app\nruntime v1"}, {"invalid protocol", "runtime protocol must match v[1-9][0-9]*", "project example.com/app\nruntime 1 example.com/provider"}, {"zero protocol", "runtime protocol must match v[1-9][0-9]*", "project example.com/app\nruntime v0 example.com/provider"}, + {"malformed protocol quote", "invalid syntax", "project example.com/app\nruntime \"bad\\q\" example.com/provider"}, {"invalid package", "runtime package", "project example.com/app\nruntime v1 ../provider"}, + {"malformed package quote", "invalid syntax", "project example.com/app\nruntime v1 \"bad\\q\""}, {"duplicate", "duplicate runtime directive in the same project", "project example.com/app\nruntime v1 example.com/provider\nruntime v1 example.com/provider"}, } for _, tt := range tests { diff --git a/runtimeprotocol/protocol_test.go b/runtimeprotocol/protocol_test.go index 7823261..4b1de26 100644 --- a/runtimeprotocol/protocol_test.go +++ b/runtimeprotocol/protocol_test.go @@ -17,6 +17,7 @@ package runtimeprotocol import ( + "path/filepath" "reflect" "strings" "testing" @@ -24,14 +25,22 @@ import ( "github.com/goplus/mod/xgomod" ) +func testPath(parts ...string) string { + path, err := filepath.Abs(filepath.Join(append([]string{"runtimeprotocol-fixture"}, parts...)...)) + if err != nil { + panic(err) + } + return path +} + func testRequest() Request { return Request{ Version: Version1, Action: ActionRun, Project: Project{ - Dir: "/workspace/app/game", - File: "/workspace/app/game/main.foo", - ModuleRoot: "/workspace/app", + 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"}, @@ -39,10 +48,22 @@ func testRequest() Request { ProviderPackage: "example.test/framework/cmd/provider", ProviderOrigin: xgomod.ResolvedModule{ Selected: xgomod.ModuleRef{Path: "example.test/framework", Version: "v1.2.3"}, - Replace: &xgomod.ModuleRef{Path: "/workspace/framework", Dir: "/workspace/framework", GoMod: "/workspace/framework/go.mod"}, + 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")}, }, - Declaration: xgomod.FileIdentity{Path: "/workspace/framework/gox.mod", SHA256: strings.Repeat("a", 64)}, - Graph: Graph{GoCommand: "/usr/bin/go", WorkDir: "/workspace/app", GoWork: "off", Flags: []string{"-mod=readonly", "-modfile=/workspace/app/alt.mod"}}, BuildFlags: []string{"-v=true", "-trimpath=true", "-buildvcs=false"}, ApplicationArgs: []string{"", "a b", "--"}, } @@ -62,7 +83,7 @@ func TestRoundTripRunReplacement(t *testing.T) { 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=/workspace/framework") { + 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] != "--" { @@ -78,10 +99,13 @@ func TestRoundTripBuildSelectedWithoutPack(t *testing.T) { want.ProviderOrigin = xgomod.ResolvedModule{ Selected: xgomod.ModuleRef{ Path: "example.test/framework", Version: "v1.2.3", - Dir: "/workspace/framework", GoMod: "/workspace/framework/go.mod", + Dir: testPath("workspace", "framework"), GoMod: testPath("workspace", "framework", "go.mod"), }, } - want.Output = &BuildOutput{Staging: "/workspace/out/.game.tmp", Final: "/workspace/out/game"} + want.Output = &BuildOutput{ + Staging: testPath("workspace", "out", ".game.tmp"), + Final: testPath("workspace", "out", "game"), + } args, err := Encode(want) if err != nil { t.Fatal(err) @@ -103,7 +127,7 @@ func TestRoundTripOriginVariantsAndWorkspace(t *testing.T) { tests := map[string]xgomod.ResolvedModule{ "main": { Selected: xgomod.ModuleRef{ - Path: "example.test/framework", Dir: "/workspace/framework", GoMod: "/workspace/framework/go.mod", + Path: "example.test/framework", Dir: testPath("workspace", "framework"), GoMod: testPath("workspace", "framework", "go.mod"), }, Main: true, }, @@ -111,7 +135,7 @@ func TestRoundTripOriginVariantsAndWorkspace(t *testing.T) { Selected: xgomod.ModuleRef{Path: "example.test/framework", Version: "v1.2.3"}, Replace: &xgomod.ModuleRef{ Path: "example.test/framework-fork", Version: "v1.4.0", - Dir: "/workspace/framework-fork", GoMod: "/workspace/framework-fork/go.mod", + Dir: testPath("workspace", "framework-fork"), GoMod: testPath("workspace", "framework-fork", "go.mod"), }, }, } @@ -119,9 +143,9 @@ func TestRoundTripOriginVariantsAndWorkspace(t *testing.T) { t.Run(name, func(t *testing.T) { want := testRequest() want.ProviderOrigin = origin - want.Declaration.Path = origin.Effective().Dir + "/gox.mod" - want.Graph.GoWork = "/workspace/go.work" - want.Graph.Flags = append(want.Graph.Flags, "-overlay=/workspace/overlay.json") + 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) @@ -139,13 +163,13 @@ func TestRoundTripOriginVariantsAndWorkspace(t *testing.T) { func TestValidationIsStructural(t *testing.T) { request := testRequest() - request.Project.Dir = "/does/not/exist/game" - request.Project.File = "/does/not/exist/game/main.foo" - request.Project.ModuleRoot = "/does/not/exist" - request.Declaration.Path = "/does/not/exist/framework/gox.mod" - request.ProviderOrigin.Replace.Path = "/does/not/exist/framework" - request.ProviderOrigin.Replace.Dir = "/does/not/exist/framework" - request.ProviderOrigin.Replace.GoMod = "/does/not/exist/framework/go.mod" + 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.ProviderOrigin.Replace.Path = testPath("does", "not", "exist", "framework") + request.ProviderOrigin.Replace.Dir = testPath("does", "not", "exist", "framework") + request.ProviderOrigin.Replace.GoMod = testPath("does", "not", "exist", "framework", "go.mod") if err := request.Validate(); err != nil { t.Fatalf("structural validation consulted ambient filesystem: %v", err) } @@ -194,6 +218,471 @@ func TestRejectMalformedArgv(t *testing.T) { } } +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 provider origin", + mutate: func(r *Request) { + r.ProviderOrigin.Selected.Path = "bad path" + }, + want: "provider origin", + }, + { + name: "declaration outside provider metadata", + mutate: func(r *Request) { + r.Declaration.Path = testPath("workspace", "framework", "metadata.txt") + }, + want: "declaration-file must be provider metadata", + }, + { + name: "invalid provider package", + mutate: func(r *Request) { + r.ProviderPackage = "bad package" + }, + want: "invalid provider 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 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.ProviderOrigin = 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-runtime" + 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: "without replacement requires", + }, + { + name: "replacement with selected source", + args: func() []string { + args := insertBeforeDelimiter(runArgs, "--selected-dir="+testPath("workspace", "framework")) + return insertBeforeDelimiter(args, "--selected-gomod="+testPath("workspace", "framework", "go.mod")) + }, + want: "with replacement forbids", + }, + { + name: "missing build output", + args: func() []string { return removeOption(buildArgs, "--output=") }, + want: "build requires --output and --final-output", + }, + { + name: "missing build final output", + args: func() []string { return removeOption(buildArgs, "--final-output=") }, + want: "build requires --output and --final-output", + }, + { + name: "run output", + args: func() []string { + return insertBeforeDelimiter(runArgs, "--output="+testPath("workspace", "out", "game")) + }, + want: "run does not accept --output", + }, + { + name: "run final output", + args: func() []string { + return insertBeforeDelimiter(runArgs, "--final-output="+testPath("workspace", "out", "game")) + }, + want: "run does not accept --final-output", + }, + { + name: "incomplete replacement", + args: func() []string { return removeOption(runArgs, "--replace-gomod=") }, + want: "replacement options must be supplied as a 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 TestRejectInvalidRequestShapes(t *testing.T) { tests := map[string]func(*Request){ "unsupported version": func(r *Request) { r.Version = "v2" }, @@ -201,28 +690,32 @@ func TestRejectInvalidRequestShapes(t *testing.T) { r.Action = ActionBuild r.ApplicationArgs = nil }, - "run with output": func(r *Request) { r.Output = &BuildOutput{Staging: "/tmp/a", Final: "/tmp/b"} }, + "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"} }, "provider outside module": func(r *Request) { r.ProviderPackage = "example.test/other/cmd/provider" }, - "flattened replacement": func(r *Request) { r.ProviderOrigin.Selected.Dir = "/workspace/framework" }, + "flattened replacement": func(r *Request) { r.ProviderOrigin.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 provider": func(r *Request) { - r.Declaration.Path = "/workspace/other/gox.mod" + r.Declaration.Path = testPath("workspace", "other", "gox.mod") }, "main origin with version": func(r *Request) { r.ProviderOrigin = xgomod.ResolvedModule{ - Selected: xgomod.ModuleRef{Path: "example.test/framework", Version: "v1.2.3", Dir: "/workspace/framework", GoMod: "/workspace/framework/go.mod"}, Main: true, + 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.ProviderOrigin.Replace.Path = "example.test/framework-fork" }, "local replace identity mismatch": func(r *Request) { - r.ProviderOrigin.Replace.Path = "/workspace/other-framework" + r.ProviderOrigin.Replace.Path = testPath("workspace", "other-framework") }, } for name, mutate := range tests { @@ -269,6 +762,22 @@ func removeOption(args []string, prefix string) []string { 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 { diff --git a/xgomod/resolved_test.go b/xgomod/resolved_test.go index 428bddf..873518d 100644 --- a/xgomod/resolved_test.go +++ b/xgomod/resolved_test.go @@ -19,11 +19,14 @@ package xgomod import ( "crypto/sha256" "encoding/hex" + "errors" "os" "path/filepath" "strings" + "syscall" "testing" + "github.com/goplus/mod/modfile" "github.com/goplus/mod/modload" "golang.org/x/mod/module" ) @@ -74,6 +77,16 @@ func graphIdentity(t *testing.T, path string) FileIdentity { 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) @@ -663,3 +676,524 @@ func TestResolvedGraphRejectsClassModWithoutRecord(t *testing.T) { 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) + } + runtimeProject := &Project{ + Ext: ".runtime", + Runtime: &modfile.Runtime{Protocol: "v1", Package: "example.com/provider"}, + } + if err := registerProject(map[string]*Project{}, map[string]*ProjectInfo{}, &ProjectInfo{Project: runtimeProject}); err == nil || !strings.Contains(err.Error(), "no module provenance") { + t.Fatalf("orphan runtime 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 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 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 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) + } + }) +} + +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.Path 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: "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 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") + } +} + +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) + } +} From 735e74af9c01a5f388b2527b3b2923839469e9a5 Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Thu, 20 Aug 2026 19:20:43 +0800 Subject: [PATCH 4/5] refactor(runtime): simplify provider protocol validation --- README.md | 4 ++ modfile/rule.go | 7 +--- modload/module.go | 11 ++--- runtimeprotocol/argv.go | 52 ++++++++++++----------- runtimeprotocol/protocol.go | 29 +++++-------- xgomod/classfile.go | 15 ++----- xgomod/resolved.go | 82 +++++++++++++++++-------------------- xgomod/resolved_test.go | 1 + 8 files changed, 89 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index 32019b1..ee3ea34 100644 --- a/README.md +++ b/README.md @@ -8,3 +8,7 @@ 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. + +## Runtime providers + +`runtimeprotocol` defines the v1 request model and argv codec used by XGo runtime providers. XGo resolves the module graph and passes its snapshot; providers verify identity-bearing paths before use. `xgomod.ImportClassesResolved` validates the target snapshot and class-module provenance without rediscovering the graph. diff --git a/modfile/rule.go b/modfile/rule.go index 3886db2..e56880b 100644 --- a/modfile/rule.go +++ b/modfile/rule.go @@ -83,10 +83,7 @@ type Pack struct { Syntax *Line } -// Runtime declares the runtime provider for a project. -// -// Protocol is the provider protocol generation (for example, "v1"). It is -// deliberately independent from the provider package's module version. +// Runtime declares a project's sole provider and independent protocol generation. type Runtime struct { Protocol string Package string @@ -102,7 +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) - Runtime *Runtime // runtime provider (at most one per project) + Runtime *Runtime // runtime provider // AutoLambdas maps command => number of parameters before auto lambda. // See https://github.com/goplus/xgo/issues/2828. diff --git a/modload/module.go b/modload/module.go index 2b15eaf..a1c12df 100644 --- a/modload/module.go +++ b/modload/module.go @@ -49,19 +49,18 @@ type Module struct { goxModIdentity FileIdentity } -// FileIdentity binds a module file path to the exact bytes read by LoadFrom. -// The zero value means that no file snapshot was loaded. +// 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 exact go.mod snapshot used to parse File. +// GoModIdentity returns the go.mod snapshot used to parse File. func (p Module) GoModIdentity() FileIdentity { return p.goModIdentity } -// GoxModIdentity returns the exact gox.mod or gop.mod snapshot used to parse Opt. +// GoxModIdentity returns the gox.mod or gop.mod snapshot used to parse Opt. func (p Module) GoxModIdentity() FileIdentity { return p.goxModIdentity } @@ -333,9 +332,7 @@ func isClass(r *gomodfile.Require) bool { return false } -// HasClassMarker reports whether comments contain an xgo:class or gop:class -// marker. A marker must end at a token boundary; optional payload must be -// separated from the marker by whitespace. +// 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, "//") { diff --git a/runtimeprotocol/argv.go b/runtimeprotocol/argv.go index 5fff2d5..d478aa5 100644 --- a/runtimeprotocol/argv.go +++ b/runtimeprotocol/argv.go @@ -80,7 +80,7 @@ type rawOptions struct { buildFlags []string } -// Encode returns the deterministic argv following the provider executable. +// Encode returns deterministic argv following the provider executable. func Encode(request Request) ([]string, error) { if err := request.Validate(); err != nil { return nil, err @@ -145,8 +145,7 @@ func Encode(request Request) ([]string, error) { return args, nil } -// Parse decodes the complete provider argv following argv[0]. Unknown, -// duplicate, partial, and action-inapplicable fields fail closed. +// Parse decodes provider argv and rejects unknown, duplicate, partial, or inapplicable fields. func Parse(args []string) (Request, error) { var request Request if len(args) < 2 { @@ -203,12 +202,11 @@ func Parse(args []string) (Request, error) { request.Declaration = xgomod.FileIdentity{ Path: raw.values["declaration-file"], SHA256: raw.values["declaration-sha256"], } - _, hasPackDir := raw.values["pack-dir"] - _, hasPackIndex := raw.values["pack-index"] - if hasPackDir != hasPackIndex { + hasPack, completePack := optionGroup(raw.values, "pack-dir", "pack-index") + if hasPack && !completePack { return Request{}, fmt.Errorf("runtimeprotocol: pack options must be supplied as a complete group") } - if hasPackDir { + if hasPack { request.Project.Pack = &Pack{Directory: raw.values["pack-dir"], IndexFile: raw.values["pack-index"]} } @@ -284,23 +282,13 @@ func parseOptions(args []string) (rawOptions, error) { } func parseModuleSource(origin *xgomod.ResolvedModule, raw rawOptions) error { - replacementCount := 0 - for _, name := range replacementOptions { - if _, ok := raw.values[name]; ok { - replacementCount++ + hasReplacement, completeReplacement := optionGroup(raw.values, replacementOptions...) + selectedDir, completeSelected := optionGroup(raw.values, "selected-dir", "selected-gomod") + if hasReplacement { + if !completeReplacement { + return fmt.Errorf("runtimeprotocol: replacement options must be supplied as a complete group") } - } - _, selectedDir := raw.values["selected-dir"] - _, selectedGoMod := raw.values["selected-gomod"] - switch replacementCount { - case 0: - if !selectedDir || !selectedGoMod { - return fmt.Errorf("runtimeprotocol: origin without replacement requires --selected-dir and --selected-gomod") - } - origin.Selected.Dir = raw.values["selected-dir"] - origin.Selected.GoMod = raw.values["selected-gomod"] - case len(replacementOptions): - if selectedDir || selectedGoMod { + if selectedDir { return fmt.Errorf("runtimeprotocol: origin with replacement forbids --selected-dir and --selected-gomod") } origin.Replace = &xgomod.ModuleRef{ @@ -309,10 +297,24 @@ func parseModuleSource(origin *xgomod.ResolvedModule, raw rawOptions) error { Dir: raw.values["replace-dir"], GoMod: raw.values["replace-gomod"], } - default: - return fmt.Errorf("runtimeprotocol: replacement options must be supplied as a complete group") + return nil + } + if !completeSelected { + return fmt.Errorf("runtimeprotocol: origin without replacement requires --selected-dir and --selected-gomod") } + origin.Selected.Dir = raw.values["selected-dir"] + origin.Selected.GoMod = raw.values["selected-gomod"] return nil } +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/runtimeprotocol/protocol.go b/runtimeprotocol/protocol.go index daf4539..22333de 100644 --- a/runtimeprotocol/protocol.go +++ b/runtimeprotocol/protocol.go @@ -14,13 +14,8 @@ * limitations under the License. */ -// Package runtimeprotocol defines the transport-neutral request model and the -// argv encoding used by XGo runtime providers. -// -// Validation in this package is deliberately structural: it validates the -// protocol shape, portable path spelling, module identities, and the bounded -// flag vocabulary without reading the filesystem. A provider must separately -// pin and verify every identity-bearing path before using it. +// Package runtimeprotocol defines the provider request model and argv codec. +// Validation is structural; consumers verify identity-bearing paths. package runtimeprotocol import ( @@ -35,14 +30,13 @@ import ( ) const ( - // Version1 is the value used by the gox.mod runtime directive. + // Version1 is the gox.mod runtime protocol value. Version1 = "v1" // PreambleV1 is the first argv element passed to a v1 provider. PreambleV1 = "xgo-runtime-v1" ) -// Action identifies the operation requested from a provider. Install uses a -// transactional build request whose final output is selected by XGo. +// Action identifies the requested provider operation. type Action string const ( @@ -50,14 +44,13 @@ const ( ActionBuild Action = "build" ) -// Pack describes optional project pack metadata. It is intentionally -// provider-neutral: a runtime that does not use a pack receives nil. +// Pack describes optional project pack metadata. type Pack struct { Directory string IndexFile string } -// Project is the immutable project snapshot discovered by XGo. +// Project is the project snapshot discovered by XGo. type Project struct { Dir string File string @@ -67,7 +60,7 @@ type Project struct { Pack *Pack } -// Graph carries the exact Go command/workspace policy used for discovery. +// Graph carries the Go command and workspace policy used for discovery. type Graph struct { GoCommand string WorkDir string @@ -75,15 +68,13 @@ type Graph struct { Flags []string } -// BuildOutput contains XGo's private staging path and user-visible final path. +// BuildOutput contains staging and final output paths. type BuildOutput struct { Staging string Final string } -// Request is one complete provider request. Run requires Output == nil and -// preserves ApplicationArgs verbatim. Build requires Output != nil and no -// application arguments. +// Request is one provider request; run has no Output, build has no ApplicationArgs. type Request struct { Version string Action Action @@ -97,7 +88,7 @@ type Request struct { ApplicationArgs []string } -// Validate checks the v1 request without consulting ambient filesystem state. +// Validate checks the request without reading the filesystem. func (r Request) Validate() error { if r.Version != Version1 { return fmt.Errorf("runtimeprotocol: unsupported version %q", r.Version) diff --git a/xgomod/classfile.go b/xgomod/classfile.go index 45e9c6f..f815ade 100644 --- a/xgomod/classfile.go +++ b/xgomod/classfile.go @@ -94,17 +94,13 @@ func (p *Module) LookupClass(ext string) (c *Project, ok bool) { return } -// LookupClassInfo looks up a classfile and returns its declaring project and -// resolved module provenance. Built-in projects have nil Origin and an empty -// RequiredXGo. +// 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 { - // Modules loaded through the legacy API predate provenance. Keep the - // lookup useful without manufacturing an origin that could be mistaken - // for a resolved graph record. + // Preserve legacy lookups without fabricating provenance. return &ProjectInfo{Project: project}, true } return nil, false @@ -132,10 +128,7 @@ func (p *Module) ImportClasses(importClass ...func(c *Project)) (err error) { return } -// ImportClassesResolved imports class metadata from an already-resolved -// module/workspace graph. The graph is validated before the receiver is -// changed. In particular, class modules come only from graph.ClassModules; the -// receiver's legacy Opt.ClassMods is never consulted by this method. +// ImportClassesResolved imports class metadata from a validated graph. func (p *Module) ImportClassesResolved(graph ResolvedClassGraph, importClass ...func(*ProjectInfo)) error { if p == nil || p.File == nil || p.Opt == nil { return fmt.Errorf("receiver has no target module snapshot") @@ -186,7 +179,7 @@ func (p *Module) ImportClassesResolved(graph ResolvedClassGraph, importClass ... } return nil } - // Built-ins are deliberately provenance-free and cannot declare a runtime. + // Built-ins have no module provenance. for _, builtin := range []*Project{TestProject, GshProject} { if err := register(&ProjectInfo{Project: builtin}); err != nil { return err diff --git a/xgomod/resolved.go b/xgomod/resolved.go index bc75440..bc28dd4 100644 --- a/xgomod/resolved.go +++ b/xgomod/resolved.go @@ -30,10 +30,7 @@ import ( "golang.org/x/mod/module" ) -// ModuleRef identifies one logical module selection or its effective source. -// Dir and GoMod are populated only for an effective source record. In -// particular, a selected module with a replacement must not copy the -// replacement's physical paths into Selected. +// ModuleRef identifies a logical selection or its effective source. type ModuleRef struct { Path string Version string @@ -41,15 +38,14 @@ type ModuleRef struct { GoMod string } -// ResolvedModule keeps the logical module selected by the graph separate from -// an optional replacement source. +// ResolvedModule separates selection from replacement source paths. type ResolvedModule struct { Selected ModuleRef Replace *ModuleRef Main bool } -// Effective returns the source selected for reading files and metadata. +// Effective returns the source used for files and metadata. func (m ResolvedModule) Effective() ModuleRef { if m.Replace != nil { return *m.Replace @@ -57,9 +53,7 @@ func (m ResolvedModule) Effective() ModuleRef { return m.Selected } -// IsLocal reports whether the module is supplied by the main module or by a -// filesystem replacement. A replacement with a release version is still a -// replacement source, not a local module. +// IsLocal reports whether the module is main or has a local replacement. func (m ResolvedModule) IsLocal() bool { return m.Main || (m.Replace != nil && m.Replace.Version == "") } @@ -69,17 +63,12 @@ func (m ResolvedModule) Validate() error { return validateResolvedModule(m) } -// ValidateSyntax checks the resolved module's logical identity and path -// spelling without reading the filesystem. Transport decoders use this to -// reject malformed provenance; consumers must call Validate before using the -// effective source. +// ValidateSyntax checks identity and path spelling without filesystem access. func (m ResolvedModule) ValidateSyntax() error { return validateResolvedModuleSyntax(m) } -// ResolvedClassGraph is the already-resolved module/workspace graph supplied by -// XGo. xgomod validates and consumes this snapshot; it never invokes the Go -// command to discover another graph. +// ResolvedClassGraph is XGo's resolved graph snapshot; it is not rediscovered. type ResolvedClassGraph struct { Target ResolvedModule ClassModules []ResolvedModule @@ -89,9 +78,7 @@ type ResolvedClassGraph struct { // FileIdentity binds metadata to the exact bytes parsed by the caller. type FileIdentity = modload.FileIdentity -// ProjectInfo is class metadata together with the module that declared it. -// Built-in GshProject and TestProject entries have no Origin and no required -// XGo version. +// ProjectInfo pairs class metadata with its origin; built-ins omit provenance. type ProjectInfo struct { Project *modfile.Project Origin *ResolvedModule @@ -153,19 +140,13 @@ func validateSource(ref ModuleRef, label string) error { if err := validateSourceSyntax(ref, label); err != nil { return err } - canonDir, err := canonicalPath(ref.Dir, true) + canonDir, err := canonicalSourcePath(ref.Dir, label+".Dir", true) if err != nil { - return fmt.Errorf("%s.Dir: %w", label, err) - } - if filepath.Clean(ref.Dir) != canonDir { - return fmt.Errorf("%s.Dir must be canonical: %q", label, ref.Dir) + return err } - canonGoMod, err := canonicalPath(ref.GoMod, false) + canonGoMod, err := canonicalSourcePath(ref.GoMod, label+".GoMod", false) if err != nil { - return fmt.Errorf("%s.GoMod: %w", label, err) - } - if filepath.Clean(ref.GoMod) != canonGoMod { - return fmt.Errorf("%s.GoMod must be canonical: %q", label, ref.GoMod) + return err } rel, err := filepath.Rel(canonDir, canonGoMod) if err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) { @@ -177,6 +158,17 @@ func validateSource(ref ModuleRef, label string) error { 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) @@ -252,12 +244,9 @@ func validateResolvedModule(m ResolvedModule) error { return validateSource(m.Selected, "selected") } if filepath.IsAbs(m.Replace.Path) { - canonPath, err := canonicalPath(m.Replace.Path, true) + _, err := canonicalSourcePath(m.Replace.Path, "replacement.Path", true) if err != nil { - return fmt.Errorf("replacement.Path: %w", err) - } - if m.Replace.Path != canonPath { - return fmt.Errorf("replacement.Path must be canonical: %q", m.Replace.Path) + return err } } return validateSource(*m.Replace, "replacement") @@ -322,20 +311,19 @@ func validateFileIdentity(identity FileIdentity) ([]byte, error) { if _, err := hex.DecodeString(identity.SHA256); err != nil { return nil, fmt.Errorf("invalid target modfile SHA-256: %w", err) } - path, err := canonicalPath(identity.Path, false) - if err != nil { - return nil, fmt.Errorf("target modfile path: %w", err) + if identity.SHA256 != strings.ToLower(identity.SHA256) { + return nil, fmt.Errorf("target modfile SHA-256 must use lowercase hexadecimal") } - if filepath.Clean(identity.Path) != path { - return nil, fmt.Errorf("target modfile path must be canonical: %q", identity.Path) + path, err := canonicalSourcePath(identity.Path, "target modfile path", false) + if err != nil { + return nil, err } data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read target modfile: %w", err) } - sum := sha256.Sum256(data) - got := hex.EncodeToString(sum[:]) - if !strings.EqualFold(got, identity.SHA256) { + got := sha256Hex(data) + if got != identity.SHA256 { return nil, fmt.Errorf("target modfile SHA-256 mismatch for %s", identity.Path) } return data, nil @@ -346,8 +334,12 @@ func fileSHA256(path string) (string, error) { if err != nil { return "", err } - sum := sha256.Sum256(b) - return hex.EncodeToString(sum[:]), nil + return sha256Hex(b), nil +} + +func sha256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) } func (g ResolvedClassGraph) validate() error { diff --git a/xgomod/resolved_test.go b/xgomod/resolved_test.go index 873518d..00dd7fd 100644 --- a/xgomod/resolved_test.go +++ b/xgomod/resolved_test.go @@ -1119,6 +1119,7 @@ func TestValidateFileIdentityRejectsMalformedShapes(t *testing.T) { {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"}, } { From 9fb832ba4693e1da6bc80a71e53ba1d780dc2e2d Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Thu, 20 Aug 2026 20:12:03 +0800 Subject: [PATCH 5/5] refactor(modfile): rename runtime protocol to driver --- README.md | 4 +- {runtimeprotocol => driverprotocol}/argv.go | 70 +++++++-------- .../protocol.go | 90 +++++++++---------- .../protocol_test.go | 52 +++++------ modfile/rule.go | 36 ++++---- modfile/rule_test.go | 75 +++++++++++----- xgomod/classfile.go | 8 +- xgomod/resolved_test.go | 22 ++--- 8 files changed, 194 insertions(+), 163 deletions(-) rename {runtimeprotocol => driverprotocol}/argv.go (73%) rename {runtimeprotocol => driverprotocol}/protocol.go (64%) rename {runtimeprotocol => driverprotocol}/protocol_test.go (93%) diff --git a/README.md b/README.md index ee3ea34..e6e641b 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,6 @@ mod - Module support for Go/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. -## Runtime providers +## Project drivers -`runtimeprotocol` defines the v1 request model and argv codec used by XGo runtime providers. XGo resolves the module graph and passes its snapshot; providers verify identity-bearing paths before use. `xgomod.ImportClassesResolved` validates the target snapshot and class-module provenance without rediscovering the graph. +`driverprotocol` defines the v1 request model and argv codec used by XGo drivers. XGo resolves the module graph and passes its snapshot; drivers verify identity-bearing paths before use. `xgomod.ImportClassesResolved` validates the target snapshot and class-module provenance without rediscovering the graph. diff --git a/runtimeprotocol/argv.go b/driverprotocol/argv.go similarity index 73% rename from runtimeprotocol/argv.go rename to driverprotocol/argv.go index d478aa5..613d147 100644 --- a/runtimeprotocol/argv.go +++ b/driverprotocol/argv.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprotocol +package driverprotocol import ( "fmt" @@ -27,7 +27,7 @@ var singularOptions = map[string]struct{}{ "project-dir": {}, "project-file": {}, "module-root": {}, - "provider-package": {}, + "driver-package": {}, "selected-path": {}, "selected-version": {}, "origin-main": {}, @@ -54,7 +54,7 @@ var commonRequiredOptions = []string{ "project-dir", "project-file", "module-root", - "provider-package", + "driver-package", "selected-path", "selected-version", "origin-main", @@ -80,7 +80,7 @@ type rawOptions struct { buildFlags []string } -// Encode returns deterministic argv following the provider executable. +// Encode returns deterministic argv following the driver executable. func Encode(request Request) ([]string, error) { if err := request.Validate(); err != nil { return nil, err @@ -91,18 +91,18 @@ func Encode(request Request) ([]string, error) { option("project-dir", request.Project.Dir), option("project-file", request.Project.File), option("module-root", request.Project.ModuleRoot), - option("provider-package", request.ProviderPackage), - option("selected-path", request.ProviderOrigin.Selected.Path), - option("selected-version", request.ProviderOrigin.Selected.Version), - option("origin-main", fmt.Sprint(request.ProviderOrigin.Main)), + 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.ProviderOrigin.Replace == nil { + if request.DriverOrigin.Replace == nil { args = append(args, - option("selected-dir", request.ProviderOrigin.Selected.Dir), - option("selected-gomod", request.ProviderOrigin.Selected.GoMod), + option("selected-dir", request.DriverOrigin.Selected.Dir), + option("selected-gomod", request.DriverOrigin.Selected.GoMod), ) } else { - replacement := request.ProviderOrigin.Replace + replacement := request.DriverOrigin.Replace args = append(args, option("replace-path", replacement.Path), option("replace-version", replacement.Version), @@ -145,19 +145,19 @@ func Encode(request Request) ([]string, error) { return args, nil } -// Parse decodes provider argv and rejects unknown, duplicate, partial, or inapplicable fields. +// Parse decodes driver argv and rejects unknown, duplicate, partial, or inapplicable fields. func Parse(args []string) (Request, error) { var request Request if len(args) < 2 { - return request, fmt.Errorf("runtimeprotocol: request requires preamble and action") + return request, fmt.Errorf("driverprotocol: request requires preamble and action") } if args[0] != PreambleV1 { - return request, fmt.Errorf("runtimeprotocol: unsupported preamble %q", args[0]) + return request, fmt.Errorf("driverprotocol: unsupported preamble %q", args[0]) } request.Version = Version1 request.Action = Action(args[1]) if request.Action != ActionRun && request.Action != ActionBuild { - return Request{}, fmt.Errorf("runtimeprotocol: unsupported action %q", args[1]) + return Request{}, fmt.Errorf("driverprotocol: unsupported action %q", args[1]) } optionArgs := args[2:] @@ -170,14 +170,14 @@ func Parse(args []string) (Request, error) { } } if delimiter < 0 { - return Request{}, fmt.Errorf("runtimeprotocol: run requires -- before application arguments") + 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("runtimeprotocol: build does not accept -- or positional arguments") + return Request{}, fmt.Errorf("driverprotocol: build does not accept -- or positional arguments") } } } @@ -188,7 +188,7 @@ func Parse(args []string) (Request, error) { } for _, name := range commonRequiredOptions { if _, ok := raw.values[name]; !ok { - return Request{}, fmt.Errorf("runtimeprotocol: option --%s is required", name) + return Request{}, fmt.Errorf("driverprotocol: option --%s is required", name) } } @@ -204,14 +204,14 @@ func Parse(args []string) (Request, error) { } hasPack, completePack := optionGroup(raw.values, "pack-dir", "pack-index") if hasPack && !completePack { - return Request{}, fmt.Errorf("runtimeprotocol: pack options must be supplied as a complete group") + 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.ProviderPackage = raw.values["provider-package"] - request.ProviderOrigin = xgomod.ResolvedModule{ + request.DriverPackage = raw.values["driver-package"] + request.DriverOrigin = xgomod.ResolvedModule{ Selected: xgomod.ModuleRef{ Path: raw.values["selected-path"], Version: raw.values["selected-version"], @@ -219,12 +219,12 @@ func Parse(args []string) (Request, error) { } switch raw.values["origin-main"] { case "true": - request.ProviderOrigin.Main = true + request.DriverOrigin.Main = true case "false": default: - return Request{}, fmt.Errorf("runtimeprotocol: invalid --origin-main %q: expected true or false", raw.values["origin-main"]) + return Request{}, fmt.Errorf("driverprotocol: invalid --origin-main %q: expected true or false", raw.values["origin-main"]) } - if err := parseModuleSource(&request.ProviderOrigin, raw); err != nil { + if err := parseModuleSource(&request.DriverOrigin, raw); err != nil { return Request{}, err } @@ -239,13 +239,13 @@ func Parse(args []string) (Request, error) { output, hasOutput := raw.values["output"] final, hasFinal := raw.values["final-output"] if !hasOutput || !hasFinal { - return Request{}, fmt.Errorf("runtimeprotocol: build requires --output and --final-output") + return Request{}, fmt.Errorf("driverprotocol: build requires --output and --final-output") } request.Output = &BuildOutput{Staging: output, Final: final} } else if _, ok := raw.values["output"]; ok { - return Request{}, fmt.Errorf("runtimeprotocol: run does not accept --output") + return Request{}, fmt.Errorf("driverprotocol: run does not accept --output") } else if _, ok := raw.values["final-output"]; ok { - return Request{}, fmt.Errorf("runtimeprotocol: run does not accept --final-output") + return Request{}, fmt.Errorf("driverprotocol: run does not accept --final-output") } if err := request.Validate(); err != nil { return Request{}, err @@ -257,11 +257,11 @@ 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("runtimeprotocol: unexpected positional argument %q", 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("runtimeprotocol: option %q must use --name=value", arg) + return rawOptions{}, fmt.Errorf("driverprotocol: option %q must use --name=value", arg) } switch name { case "graph-flag": @@ -270,10 +270,10 @@ func parseOptions(args []string) (rawOptions, error) { raw.buildFlags = append(raw.buildFlags, value) default: if _, ok := singularOptions[name]; !ok { - return rawOptions{}, fmt.Errorf("runtimeprotocol: unknown option --%s", name) + return rawOptions{}, fmt.Errorf("driverprotocol: unknown option --%s", name) } if _, duplicate := raw.values[name]; duplicate { - return rawOptions{}, fmt.Errorf("runtimeprotocol: option --%s may not be repeated", name) + return rawOptions{}, fmt.Errorf("driverprotocol: option --%s may not be repeated", name) } raw.values[name] = value } @@ -286,10 +286,10 @@ func parseModuleSource(origin *xgomod.ResolvedModule, raw rawOptions) error { selectedDir, completeSelected := optionGroup(raw.values, "selected-dir", "selected-gomod") if hasReplacement { if !completeReplacement { - return fmt.Errorf("runtimeprotocol: replacement options must be supplied as a complete group") + return fmt.Errorf("driverprotocol: replacement options must be supplied as a complete group") } if selectedDir { - return fmt.Errorf("runtimeprotocol: origin with replacement forbids --selected-dir and --selected-gomod") + return fmt.Errorf("driverprotocol: origin with replacement forbids --selected-dir and --selected-gomod") } origin.Replace = &xgomod.ModuleRef{ Path: raw.values["replace-path"], @@ -300,7 +300,7 @@ func parseModuleSource(origin *xgomod.ResolvedModule, raw rawOptions) error { return nil } if !completeSelected { - return fmt.Errorf("runtimeprotocol: origin without replacement requires --selected-dir and --selected-gomod") + return fmt.Errorf("driverprotocol: origin without replacement requires --selected-dir and --selected-gomod") } origin.Selected.Dir = raw.values["selected-dir"] origin.Selected.GoMod = raw.values["selected-gomod"] diff --git a/runtimeprotocol/protocol.go b/driverprotocol/protocol.go similarity index 64% rename from runtimeprotocol/protocol.go rename to driverprotocol/protocol.go index 22333de..71552c1 100644 --- a/runtimeprotocol/protocol.go +++ b/driverprotocol/protocol.go @@ -14,9 +14,9 @@ * limitations under the License. */ -// Package runtimeprotocol defines the provider request model and argv codec. +// Package driverprotocol defines the driver request model and argv codec. // Validation is structural; consumers verify identity-bearing paths. -package runtimeprotocol +package driverprotocol import ( "encoding/hex" @@ -30,13 +30,13 @@ import ( ) const ( - // Version1 is the gox.mod runtime protocol value. + // Version1 is the gox.mod driver protocol value. Version1 = "v1" - // PreambleV1 is the first argv element passed to a v1 provider. - PreambleV1 = "xgo-runtime-v1" + // PreambleV1 is the first argv element passed to a v1 driver. + PreambleV1 = "xgo-driver-v1" ) -// Action identifies the requested provider operation. +// Action identifies the requested driver operation. type Action string const ( @@ -74,13 +74,13 @@ type BuildOutput struct { Final string } -// Request is one provider request; run has no Output, build has no ApplicationArgs. +// Request is one driver request; run has no Output, build has no ApplicationArgs. type Request struct { Version string Action Action Project Project - ProviderPackage string - ProviderOrigin xgomod.ResolvedModule + DriverPackage string + DriverOrigin xgomod.ResolvedModule Declaration xgomod.FileIdentity Graph Graph BuildFlags []string @@ -91,10 +91,10 @@ type Request struct { // Validate checks the request without reading the filesystem. func (r Request) Validate() error { if r.Version != Version1 { - return fmt.Errorf("runtimeprotocol: unsupported version %q", r.Version) + return fmt.Errorf("driverprotocol: unsupported version %q", r.Version) } if r.Action != ActionRun && r.Action != ActionBuild { - return fmt.Errorf("runtimeprotocol: unsupported action %q", r.Action) + return fmt.Errorf("driverprotocol: unsupported action %q", r.Action) } for _, item := range []struct { name string @@ -112,16 +112,16 @@ func (r Request) Validate() error { } } if filepath.Dir(r.Project.File) != r.Project.Dir { - return fmt.Errorf("runtimeprotocol: project-file must be a top-level file in 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("runtimeprotocol: project-dir must be within module-root") + 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("runtimeprotocol: project extension may not be empty or contain NUL") + 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("runtimeprotocol: project full extension may not be empty or contain NUL") + 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 { @@ -131,22 +131,22 @@ func (r Request) Validate() error { return err } } - if err := validateProviderOrigin(r.ProviderOrigin); err != nil { - return fmt.Errorf("runtimeprotocol: provider origin: %w", err) + if err := validateDriverOrigin(r.DriverOrigin); err != nil { + return fmt.Errorf("driverprotocol: driver origin: %w", err) } if err := validateSHA256("declaration-sha256", r.Declaration.SHA256); err != nil { return err } - effective := r.ProviderOrigin.Effective() + 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("runtimeprotocol: declaration-file must be provider metadata (gox.mod or gop.mod) in %q", effective.Dir) + return fmt.Errorf("driverprotocol: declaration-file must be driver metadata (gox.mod or gop.mod) in %q", effective.Dir) } - if err := module.CheckImportPath(r.ProviderPackage); err != nil { - return fmt.Errorf("runtimeprotocol: invalid provider package %q: %w", r.ProviderPackage, err) + if err := module.CheckImportPath(r.DriverPackage); err != nil { + return fmt.Errorf("driverprotocol: invalid driver package %q: %w", r.DriverPackage, err) } - if !moduleContainsPackage(r.ProviderOrigin.Selected.Path, r.ProviderPackage) { - return fmt.Errorf("runtimeprotocol: provider package %q is outside selected module %q", r.ProviderPackage, r.ProviderOrigin.Selected.Path) + 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 { @@ -161,20 +161,20 @@ func (r Request) Validate() error { } for _, arg := range r.ApplicationArgs { if strings.IndexByte(arg, 0) >= 0 { - return fmt.Errorf("runtimeprotocol: application argument contains NUL") + return fmt.Errorf("driverprotocol: application argument contains NUL") } } switch r.Action { case ActionRun: if r.Output != nil { - return fmt.Errorf("runtimeprotocol: run request cannot contain output paths") + return fmt.Errorf("driverprotocol: run request cannot contain output paths") } case ActionBuild: if r.Output == nil { - return fmt.Errorf("runtimeprotocol: build request requires output paths") + return fmt.Errorf("driverprotocol: build request requires output paths") } if len(r.ApplicationArgs) != 0 { - return fmt.Errorf("runtimeprotocol: build request cannot contain application arguments") + return fmt.Errorf("driverprotocol: build request cannot contain application arguments") } if err := validateAbsolutePath("output", r.Output.Staging); err != nil { return err @@ -183,55 +183,55 @@ func (r Request) Validate() error { return err } if r.Output.Staging == r.Output.Final { - return fmt.Errorf("runtimeprotocol: output and final-output must be different paths") + return fmt.Errorf("driverprotocol: output and final-output must be different paths") } } return nil } -func validateProviderOrigin(origin xgomod.ResolvedModule) error { +func validateDriverOrigin(origin xgomod.ResolvedModule) error { return origin.ValidateSyntax() } func validateAbsolutePath(name, value string) error { if value == "" || strings.IndexByte(value, 0) >= 0 { - return fmt.Errorf("runtimeprotocol: path --%s may not be empty or contain NUL", name) + return fmt.Errorf("driverprotocol: path --%s may not be empty or contain NUL", name) } if !filepath.IsAbs(value) { - return fmt.Errorf("runtimeprotocol: path --%s must be absolute: %q", name, value) + return fmt.Errorf("driverprotocol: path --%s must be absolute: %q", name, value) } if filepath.Clean(value) != value { - return fmt.Errorf("runtimeprotocol: path --%s must be clean: %q", name, 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("runtimeprotocol: pack directory must be a clean non-empty relative slash path: %q", 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("runtimeprotocol: pack directory escapes the project: %q", 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("runtimeprotocol: pack index must be a plain file name: %q", value) + 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("runtimeprotocol: --%s must contain 64 hexadecimal characters", name) + return fmt.Errorf("driverprotocol: --%s must contain 64 hexadecimal characters", name) } if _, err := hex.DecodeString(value); err != nil { - return fmt.Errorf("runtimeprotocol: --%s is not a SHA-256 digest: %w", name, err) + return fmt.Errorf("driverprotocol: --%s is not a SHA-256 digest: %w", name, err) } if value != strings.ToLower(value) { - return fmt.Errorf("runtimeprotocol: --%s must use lowercase hexadecimal", name) + return fmt.Errorf("driverprotocol: --%s must use lowercase hexadecimal", name) } return nil } @@ -241,14 +241,14 @@ func validateGraphFlags(flags []string) error { switch name { case "mod": if value != "mod" && value != "readonly" && value != "vendor" { - return fmt.Errorf("runtimeprotocol: graph flag -mod has unsupported value %q", value) + 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("runtimeprotocol: graph flag -%s is not supported", name) + return fmt.Errorf("driverprotocol: graph flag -%s is not supported", name) } return nil }) @@ -259,14 +259,14 @@ func validateBuildFlags(flags []string) error { switch name { case "v", "x", "work", "trimpath": if value != "true" { - return fmt.Errorf("runtimeprotocol: build flag -%s has unsupported value %q", name, value) + return fmt.Errorf("driverprotocol: build flag -%s has unsupported value %q", name, value) } case "buildvcs": if value != "false" { - return fmt.Errorf("runtimeprotocol: build flag -buildvcs has unsupported value %q", value) + return fmt.Errorf("driverprotocol: build flag -buildvcs has unsupported value %q", value) } default: - return fmt.Errorf("runtimeprotocol: build flag -%s is not supported", name) + return fmt.Errorf("driverprotocol: build flag -%s is not supported", name) } return nil }) @@ -277,10 +277,10 @@ func validateFlags(kind string, flags []string, validateValue func(name, value s for _, flag := range flags { name, value, ok := splitCanonicalFlag(flag) if !ok { - return fmt.Errorf("runtimeprotocol: %s flag %q must use -name=value", kind, flag) + return fmt.Errorf("driverprotocol: %s flag %q must use -name=value", kind, flag) } if _, duplicate := seen[name]; duplicate { - return fmt.Errorf("runtimeprotocol: %s flag -%s may not be repeated", kind, name) + return fmt.Errorf("driverprotocol: %s flag -%s may not be repeated", kind, name) } seen[name] = struct{}{} if err := validateValue(name, value); err != nil { diff --git a/runtimeprotocol/protocol_test.go b/driverprotocol/protocol_test.go similarity index 93% rename from runtimeprotocol/protocol_test.go rename to driverprotocol/protocol_test.go index 4b1de26..15d9ddd 100644 --- a/runtimeprotocol/protocol_test.go +++ b/driverprotocol/protocol_test.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package runtimeprotocol +package driverprotocol import ( "path/filepath" @@ -26,7 +26,7 @@ import ( ) func testPath(parts ...string) string { - path, err := filepath.Abs(filepath.Join(append([]string{"runtimeprotocol-fixture"}, parts...)...)) + path, err := filepath.Abs(filepath.Join(append([]string{"driverprotocol-fixture"}, parts...)...)) if err != nil { panic(err) } @@ -45,8 +45,8 @@ func testRequest() Request { FullExtension: "*.foo", Pack: &Pack{Directory: "payload", IndexFile: "index.data"}, }, - ProviderPackage: "example.test/framework/cmd/provider", - ProviderOrigin: xgomod.ResolvedModule{ + 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"), @@ -96,7 +96,7 @@ func TestRoundTripBuildSelectedWithoutPack(t *testing.T) { want.Action = ActionBuild want.ApplicationArgs = nil want.Project.Pack = nil - want.ProviderOrigin = xgomod.ResolvedModule{ + 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"), @@ -142,7 +142,7 @@ func TestRoundTripOriginVariantsAndWorkspace(t *testing.T) { for name, origin := range tests { t.Run(name, func(t *testing.T) { want := testRequest() - want.ProviderOrigin = origin + 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")) @@ -167,15 +167,15 @@ func TestValidationIsStructural(t *testing.T) { 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.ProviderOrigin.Replace.Path = testPath("does", "not", "exist", "framework") - request.ProviderOrigin.Replace.Dir = testPath("does", "not", "exist", "framework") - request.ProviderOrigin.Replace.GoMod = testPath("does", "not", "exist", "framework", "go.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 TestPackDotIsProviderNeutral(t *testing.T) { +func TestPackDotIsDriverNeutral(t *testing.T) { request := testRequest() request.Project.Pack.Directory = "." args, err := Encode(request) @@ -358,25 +358,25 @@ func TestValidateRejectsStructuralRequests(t *testing.T) { want: "pack index must be a plain file name", }, { - name: "invalid provider origin", + name: "invalid driver origin", mutate: func(r *Request) { - r.ProviderOrigin.Selected.Path = "bad path" + r.DriverOrigin.Selected.Path = "bad path" }, - want: "provider origin", + want: "driver origin", }, { - name: "declaration outside provider metadata", + name: "declaration outside driver metadata", mutate: func(r *Request) { r.Declaration.Path = testPath("workspace", "framework", "metadata.txt") }, - want: "declaration-file must be provider metadata", + want: "declaration-file must be driver metadata", }, { - name: "invalid provider package", + name: "invalid driver package", mutate: func(r *Request) { - r.ProviderPackage = "bad package" + r.DriverPackage = "bad package" }, - want: "invalid provider package", + want: "invalid driver package", }, { name: "relative go work", @@ -536,7 +536,7 @@ func TestParseRejectsMalformedRequests(t *testing.T) { buildRequest.Action = ActionBuild buildRequest.ApplicationArgs = nil buildRequest.Project.Pack = nil - buildRequest.ProviderOrigin = xgomod.ResolvedModule{ + 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"), @@ -570,7 +570,7 @@ func TestParseRejectsMalformedRequests(t *testing.T) { name: "unsupported preamble", args: func() []string { args := append([]string(nil), runArgs...) - args[0] = "other-runtime" + args[0] = "other-driver" return args }, want: "unsupported preamble", @@ -695,15 +695,15 @@ func TestRejectInvalidRequestShapes(t *testing.T) { "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"} }, - "provider outside module": func(r *Request) { r.ProviderPackage = "example.test/other/cmd/provider" }, - "flattened replacement": func(r *Request) { r.ProviderOrigin.Selected.Dir = testPath("workspace", "framework") }, + "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 provider": func(r *Request) { + "declaration outside driver": func(r *Request) { r.Declaration.Path = testPath("workspace", "other", "gox.mod") }, "main origin with version": func(r *Request) { - r.ProviderOrigin = xgomod.ResolvedModule{ + 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"), @@ -712,10 +712,10 @@ func TestRejectInvalidRequestShapes(t *testing.T) { } }, "local replace with module path": func(r *Request) { - r.ProviderOrigin.Replace.Path = "example.test/framework-fork" + r.DriverOrigin.Replace.Path = "example.test/framework-fork" }, "local replace identity mismatch": func(r *Request) { - r.ProviderOrigin.Replace.Path = testPath("workspace", "other-framework") + r.DriverOrigin.Replace.Path = testPath("workspace", "other-framework") }, } for name, mutate := range tests { diff --git a/modfile/rule.go b/modfile/rule.go index e56880b..693a1dc 100644 --- a/modfile/rule.go +++ b/modfile/rule.go @@ -83,8 +83,8 @@ type Pack struct { Syntax *Line } -// Runtime declares a project's sole provider and independent protocol generation. -type Runtime struct { +// Driver declares a project's driver package and protocol version. +type Driver struct { Protocol string Package string Syntax *Line @@ -99,7 +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) - Runtime *Runtime // runtime provider + Driver *Driver // project driver // AutoLambdas maps command => number of parameters before auto lambda. // See https://github.com/goplus/xgo/issues/2828. @@ -185,7 +185,7 @@ 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 == "runtime" && len(x.Line) == 0 { + if (verb == "driver" || verb == "runtime") && 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 } @@ -403,22 +403,22 @@ usage: class [-embed -prefix=Prefix] *.workExt WorkClass [WorkPrototype]`, sw) return } proj.Pack = &Pack{Directory: dir, IndexFile: indexFile, Syntax: line} - case "runtime": + case "driver": if line.InBlock { - errorf("runtime directive must not be a block") + errorf("driver directive must not be a block") return } proj := f.proj() if proj == nil { - errorf("runtime must declare after a project definition") + errorf("driver must declare after a project definition") return } - if proj.Runtime != nil { - errorf("duplicate runtime directive in the same project") + if proj.Driver != nil { + errorf("duplicate driver directive in the same project") return } if len(args) != 2 { - errorf("usage: runtime ") + errorf("usage: driver ") return } protocol, err := parseString(&args[0]) @@ -426,8 +426,8 @@ usage: class [-embed -prefix=Prefix] *.workExt WorkClass [WorkPrototype]`, sw) wrapError(err) return } - if !runtimeProtocolRE.MatchString(protocol) { - errorf("runtime protocol must match v[1-9][0-9]*, got %q", protocol) + if !driverProtocolRE.MatchString(protocol) { + errorf("driver protocol must match v[1-9][0-9]*, got %q", protocol) return } pkgPath, err := parseString(&args[1]) @@ -436,10 +436,12 @@ usage: class [-embed -prefix=Prefix] *.workExt WorkClass [WorkPrototype]`, sw) return } if err := module.CheckImportPath(pkgPath); err != nil { - errorf("runtime package %q is not a valid import path: %v", pkgPath, err) + errorf("driver package %q is not a valid import path: %v", pkgPath, err) return } - proj.Runtime = &Runtime{Protocol: protocol, Package: pkgPath, Syntax: line} + proj.Driver = &Driver{Protocol: protocol, Package: pkgPath, Syntax: line} + case "runtime": + errorf("runtime directive was renamed to driver") case "autolambda": proj := f.proj() if proj == nil { @@ -535,9 +537,9 @@ func AutoQuote(s string) string { } var ( - typeRE = regexp.MustCompile(`\*?[A-Z]\w*`) - idenRE = regexp.MustCompile(`\w+`) - runtimeProtocolRE = regexp.MustCompile(`^v[1-9][0-9]*$`) + 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 9b331b5..61e26d5 100644 --- a/modfile/rule_test.go +++ b/modfile/rule_test.go @@ -158,48 +158,48 @@ func TestParsePack(t *testing.T) { } } -func TestParseRuntime(t *testing.T) { +func TestParseDriver(t *testing.T) { const src = ` xgo 1.6 project main.foo Game example.com/framework math -runtime v1 example.com/framework/cmd/runtime // provider +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.Runtime == nil { - t.Fatal("expected runtime") + if proj == nil || proj.Driver == nil { + t.Fatal("expected driver") } - if proj.Runtime.Protocol != "v1" || proj.Runtime.Package != "example.com/framework/cmd/runtime" { - t.Fatalf("runtime = %#v", proj.Runtime) + 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().Runtime; got == nil || got.Protocol != "v1" || got.Package != proj.Runtime.Package { - t.Fatalf("round-trip runtime = %#v", got) + if got := f2.proj().Driver; got == nil || got.Protocol != "v1" || got.Package != proj.Driver.Package { + t.Fatalf("round-trip driver = %#v", got) } } -func TestParseRuntimeErrors(t *testing.T) { +func TestParseDriverErrors(t *testing.T) { tests := []struct { name string want string src string }{ - {"before project", "runtime must declare after a project definition", "runtime v1 example.com/provider"}, - {"wrong arity", "usage: runtime ", "project example.com/app\nruntime v1"}, - {"invalid protocol", "runtime protocol must match v[1-9][0-9]*", "project example.com/app\nruntime 1 example.com/provider"}, - {"zero protocol", "runtime protocol must match v[1-9][0-9]*", "project example.com/app\nruntime v0 example.com/provider"}, - {"malformed protocol quote", "invalid syntax", "project example.com/app\nruntime \"bad\\q\" example.com/provider"}, - {"invalid package", "runtime package", "project example.com/app\nruntime v1 ../provider"}, - {"malformed package quote", "invalid syntax", "project example.com/app\nruntime v1 \"bad\\q\""}, - {"duplicate", "duplicate runtime directive in the same project", "project example.com/app\nruntime v1 example.com/provider\nruntime v1 example.com/provider"}, + {"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) { @@ -216,16 +216,45 @@ func TestParseRuntimeErrors(t *testing.T) { } } -func TestParseRuntimeIsNotABlockDirective(t *testing.T) { +func TestParseRejectsLegacyRuntimeDirective(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("project example.com/app\nruntime v1 example.com/driver\n")) + if err == nil || !strings.Contains(err.Error(), "runtime directive was renamed to driver") { + t.Fatalf("error = %v", err) + } + } +} + +func TestParseRejectsLegacyRuntimeBlock(t *testing.T) { + for _, src := range []string{ + "project example.com/app\nruntime (\nv1 example.com/driver\n)\n", + "project example.com/app\nruntime (\n)\n", + } { + 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(src)) + if err == nil || !strings.Contains(err.Error(), "runtime directive was renamed to driver") { + t.Fatalf("error = %v", err) + } + } + } +} + +func TestParseDriverIsNotABlockDirective(t *testing.T) { _, err := ParseLax("gox.mod", []byte(`project example.com/app -runtime ( -v1 example.com/provider +driver ( +v1 example.com/driver )`), nil) - if err == nil || !strings.Contains(err.Error(), "runtime directive must not be a block") { + 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\nruntime (\n)\n"), nil) - if err == nil || !strings.Contains(err.Error(), "runtime directive must not be a block") { + _, 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) } } diff --git a/xgomod/classfile.go b/xgomod/classfile.go index f815ade..81cfefb 100644 --- a/xgomod/classfile.go +++ b/xgomod/classfile.go @@ -259,16 +259,16 @@ func registerProject(projects map[string]*Project, infos map[string]*ProjectInfo if info == nil || info.Project == nil { return fmt.Errorf("class metadata contains a nil project") } - if info.Project.Runtime != nil && info.Origin == nil { - return fmt.Errorf("runtime project %q has no module provenance", info.Project.Ext) + 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.Runtime != nil || info.Project.Runtime != nil { - return fmt.Errorf("runtime class extension collision for %q between %q and %q", ext, old.Project.Class, info.Project.Class) + 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 diff --git a/xgomod/resolved_test.go b/xgomod/resolved_test.go index 00dd7fd..17dddec 100644 --- a/xgomod/resolved_test.go +++ b/xgomod/resolved_test.go @@ -243,7 +243,7 @@ func TestImportClassesResolvedProvenanceAndSelfOverlap(t *testing.T) { project .foo Game example.com/app class .foo Sprite - runtime v1 example.com/app/cmd/runtime + 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 { @@ -276,7 +276,7 @@ project .dep Dep example.com/class t.Fatal(err) } targetInfo, ok := m.LookupClassInfo(".foo") - if !ok || targetInfo.Project.Runtime == nil { + 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" { @@ -509,15 +509,15 @@ func TestResolvedGraphRejectsUnrelatedExternalGoMod(t *testing.T) { } } -func TestImportClassesResolvedRuntimeCollision(t *testing.T) { +func TestImportClassesResolvedDriverBackedCollision(t *testing.T) { root := t.TempDir() - targetGox := "xgo 1.9\nproject .foo Game example.com/app\nruntime v1 example.com/app/runtime\n" + 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\nruntime v1 example.com/class/runtime\n" + 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 { @@ -528,7 +528,7 @@ func TestImportClassesResolvedRuntimeCollision(t *testing.T) { 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(), "runtime class extension collision") { + if err == nil || !strings.Contains(err.Error(), "driver-backed class extension collision") { t.Fatalf("error = %v", err) } } @@ -691,12 +691,12 @@ func TestLookupClassInfoLegacyFallbackAndRegistrationSafety(t *testing.T) { if err := registerProject(nil, nil, nil); err == nil || !strings.Contains(err.Error(), "nil project") { t.Fatalf("nil project error = %v", err) } - runtimeProject := &Project{ - Ext: ".runtime", - Runtime: &modfile.Runtime{Protocol: "v1", Package: "example.com/provider"}, + driverBackedProject := &Project{ + Ext: ".driver", + Driver: &modfile.Driver{Protocol: "v1", Package: "example.com/driver"}, } - if err := registerProject(map[string]*Project{}, map[string]*ProjectInfo{}, &ProjectInfo{Project: runtimeProject}); err == nil || !strings.Contains(err.Error(), "no module provenance") { - t.Fatalf("orphan runtime error = %v", err) + 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"}