diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 9283f98d9..26a46cda1 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -46,6 +46,7 @@ import ( cargotech "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/cargo" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/docker" gemtech "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/gem" + _go "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/go" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/huggingface" hfdiscovery "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/huggingface/discovery" npmtech "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/npm" @@ -3298,11 +3299,17 @@ func getNugetNameScopeAndVersion(id, artiUrl, repo string) (downloadUrls []strin // output: downloadUrl: /api/go/go/github.com/kennygrant/sanitize/@v/v1.2.4.zip func getGoNameScopeAndVersion(id, artiUrl, repo string) (downloadUrls []string, name, scope, version string) { id = strings.TrimPrefix(id, techutils.Go.String()+"://") + // A module satisfied by a filesystem 'replace' directive was never published - skip probing it. + isLocalReplace := strings.HasSuffix(id, _go.LocalReplaceMarker) + id = strings.TrimSuffix(id, _go.LocalReplaceMarker) nameVersion := strings.Split(id, ":") name = nameVersion[0] if len(nameVersion) > 1 { version = nameVersion[1] } + if isLocalReplace { + return nil, name, "", version + } url := strings.TrimSuffix(artiUrl, "/") + "/api/go/" + repo + "/" + name + "/@v/" + version + ".zip" return []string{url}, name, "", version } diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index 1c14cab3d..4a4039b3a 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -20,6 +20,7 @@ import ( "testing" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies" + _go "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/go" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/java" "github.com/jfrog/jfrog-cli-security/utils/formats" @@ -685,21 +686,41 @@ func getTestCasesForDoCurationAudit() []testCase { pathToProject: filepath.Join("projects", "package-managers", "go", "curation-project"), createServerWithoutCreds: true, serveResources: map[string]string{ - "v1.5.2.mod": filepath.Join("resources", "quote-v1.5.2.mod"), - "v1.5.2.zip": filepath.Join("resources", "quote-v1.5.2.zip"), - "v1.5.2.info": filepath.Join("resources", "quote-v1.5.2.info"), - "v1.3.0.mod": filepath.Join("resources", "sampler-v1.3.0.mod"), - "v1.3.0.zip": filepath.Join("resources", "sampler-v1.3.0.zip"), - "v1.3.0.info": filepath.Join("resources", "sampler-v1.3.0.info"), - "v0.0.0-20170915032832-14c0d48ead0c.mod": filepath.Join("resources", "text-v0.0.0-20170915032832-14c0d48ead0c.mod"), - "v0.0.0-20170915032832-14c0d48ead0c.zip": filepath.Join("resources", "text-v0.0.0-20170915032832-14c0d48ead0c.zip"), + "v1.5.2.mod": filepath.Join("resources", "quote-v1.5.2.mod"), + "v1.5.2.zip": filepath.Join("resources", "quote-v1.5.2.zip"), + "v1.5.2.info": filepath.Join("resources", "quote-v1.5.2.info"), + "v1.3.0.mod": filepath.Join("resources", "sampler-v1.3.0.mod"), + "v1.3.0.zip": filepath.Join("resources", "sampler-v1.3.0.zip"), + "v1.3.0.info": filepath.Join("resources", "sampler-v1.3.0.info"), + "v0.0.0-20170915032832-14c0d48ead0c.mod": filepath.Join("resources", "text-v0.0.0-20170915032832-14c0d48ead0c.mod"), + "v0.0.0-20170915032832-14c0d48ead0c.zip": filepath.Join("resources", "text-v0.0.0-20170915032832-14c0d48ead0c.zip"), "v0.0.0-20170915032832-14c0d48ead0c.info": filepath.Join("resources", "text-v0.0.0-20170915032832-14c0d48ead0c.info"), }, + // example.com/localmod is a tripwire, not an expected call: it's local-replaced and must never + // be probed. If a regression ever probes it, this mock 403s it, breaking expectedResp below. requestToFail: map[string]bool{ - "/api/go/go-virtual/rsc.io/sampler/@v/v1.3.0.zip": false, + "/api/go/go-virtual/rsc.io/sampler/@v/v1.3.0.zip": false, + "/api/go/go-virtual/example.com/localmod/@v/v0.0.0.zip": false, }, expectedResp: map[string]*CurationReport{ "github.com/you/hello": {packagesStatus: []*PackageStatus{ + { + Action: "blocked", + ParentName: "example.com/localmod", + ParentVersion: "v0.0.0", + BlockedPackageUrl: "/api/go/go-virtual/rsc.io/sampler/@v/v1.3.0.zip", + PackageName: "rsc.io/sampler", + PackageVersion: "v1.3.0", + BlockingReason: "Policy violations", + DepRelation: "indirect", + PkgType: "go", + Policy: []Policy{ + { + Policy: "pol1", + Condition: "cond1", + }, + }, + }, { Action: "blocked", ParentName: "rsc.io/quote", @@ -735,7 +756,7 @@ func getTestCasesForDoCurationAudit() []testCase { }, }, }, - totalNumberOfPackages: 3, + totalNumberOfPackages: 4, }, }, }, @@ -1230,6 +1251,15 @@ func Test_getGoNameScopeAndVersion(t *testing.T) { compName: "github.com/kennygrant/sanitize", version: "v1.2.4", }, + { + name: "local replace go component id is skipped", + compId: "go://github.com/example/localmod:v0.0.0" + _go.LocalReplaceMarker, + rtUrl: "http://test/artifactory", + repo: "test", + downloadUrls: nil, + compName: "github.com/example/localmod", + version: "v0.0.0", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/go.mod b/go.mod index 19de20fdb..2a9d68053 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/urfave/cli v1.22.17 github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74 golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3 + golang.org/x/mod v0.37.0 golang.org/x/sync v0.22.0 golang.org/x/sys v0.47.0 golang.org/x/text v0.40.0 @@ -147,7 +148,6 @@ require ( go.opentelemetry.io/otel/trace v1.42.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.52.0 // indirect - golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/term v0.45.0 // indirect diff --git a/sca/bom/buildinfo/technologies/go/golang.go b/sca/bom/buildinfo/technologies/go/golang.go index 6626dd554..c9741ec90 100644 --- a/sca/bom/buildinfo/technologies/go/golang.go +++ b/sca/bom/buildinfo/technologies/go/golang.go @@ -2,6 +2,8 @@ package _go import ( "errors" + "os" + "path/filepath" "strings" biutils "github.com/jfrog/build-info-go/utils" @@ -14,11 +16,15 @@ import ( "github.com/jfrog/jfrog-client-go/utils/errorutils" "github.com/jfrog/jfrog-client-go/utils/log" xrayUtils "github.com/jfrog/jfrog-client-go/xray/services/utils" + "golang.org/x/mod/modfile" ) const ( goPackageTypeIdentifier = "go://" goSourceCodePrefix = "github.com/golang/go:v" + // LocalReplaceMarker tags a tree node whose module is satisfied by a local 'replace' directive. + // It's never published, so never probe it. + LocalReplaceMarker = ":local-replace" ) func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (dependencyTree []*xrayUtils.GraphNode, uniqueDeps []string, err error) { @@ -65,13 +71,21 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (depen if err != nil { return } + + // Modules satisfied by a local filesystem 'replace' directive were never published and must not be + // probed against Artifactory during curation-audit. + var localReplaceModules map[string]map[string]bool + if params.IsCurationCmd { + localReplaceModules = getLocalReplaceModules(currentDir) + } + // Parse the dependencies into Xray dependency tree format rootNode := &xrayUtils.GraphNode{ Id: goPackageTypeIdentifier + rootModuleName, Nodes: []*xrayUtils.GraphNode{}, } uniqueDepsSet := datastructures.MakeSet[string]() - populateGoDependencyTree(rootNode, dependenciesGraph, dependenciesList, uniqueDepsSet) + populateGoDependencyTree(rootNode, dependenciesGraph, dependenciesList, uniqueDepsSet, localReplaceModules) // In case of curation command, go version is not relevant as it can't be resolved from go repo if !params.IsCurationCmd { @@ -106,26 +120,70 @@ func handleCurationGoError(err error) (bool, error) { return false, nil } -func populateGoDependencyTree(currNode *xrayUtils.GraphNode, dependenciesGraph map[string][]string, dependenciesList map[string]bool, uniqueDepsSet *datastructures.Set[string]) { +func populateGoDependencyTree(currNode *xrayUtils.GraphNode, dependenciesGraph map[string][]string, dependenciesList map[string]bool, uniqueDepsSet *datastructures.Set[string], localReplaceModules map[string]map[string]bool) { if currNode.NodeHasLoop() { return } uniqueDepsSet.Add(currNode.Id) - currDepChildren := dependenciesGraph[strings.TrimPrefix(currNode.Id, goPackageTypeIdentifier)] + // Strip our marker before the graph lookup, so a local module's own (real) dependencies are still found. + graphKey := strings.TrimSuffix(strings.TrimPrefix(currNode.Id, goPackageTypeIdentifier), LocalReplaceMarker) + currDepChildren := dependenciesGraph[graphKey] // Recursively create & append all node's dependencies. for _, childName := range currDepChildren { if !dependenciesList[childName] { // 'go list all' is more accurate than 'go graph' so we filter out deps that don't exist in go list continue } + childId := goPackageTypeIdentifier + childName + if isLocalReplaceModule(childName, localReplaceModules) { + childId += LocalReplaceMarker + } childNode := &xrayUtils.GraphNode{ - Id: goPackageTypeIdentifier + childName, + Id: childId, Nodes: []*xrayUtils.GraphNode{}, Parent: currNode, } currNode.Nodes = append(currNode.Nodes, childNode) - populateGoDependencyTree(childNode, dependenciesGraph, dependenciesList, uniqueDepsSet) + populateGoDependencyTree(childNode, dependenciesGraph, dependenciesList, uniqueDepsSet, localReplaceModules) + } +} + +// isLocalReplaceModule reports whether childName (":") is covered by a local replace. "" +// in the versions set means unconditional (any version); otherwise only that exact pinned version matches. +func isLocalReplaceModule(childName string, localReplaceModules map[string]map[string]bool) bool { + modulePath, version, found := strings.Cut(childName, ":") + if !found { + modulePath = childName + version = "" + } + versions := localReplaceModules[modulePath] + return versions[""] || versions[version] +} + +// getLocalReplaceModules returns, per module path, the versions go.mod at projectDir replaces with a local +// directory ("" = unconditional). Module-to-module replaces are excluded. Fails open on read/parse errors. +func getLocalReplaceModules(projectDir string) map[string]map[string]bool { + localReplaceModules := map[string]map[string]bool{} + goModPath := filepath.Join(projectDir, "go.mod") + data, err := os.ReadFile(goModPath) + if err != nil { + log.Warn("curation-audit: failed reading go.mod to detect local 'replace' directives, local modules will not be skipped: " + err.Error()) + return localReplaceModules + } + modFile, err := modfile.Parse(goModPath, data, nil) + if err != nil { + log.Warn("curation-audit: failed parsing go.mod to detect local 'replace' directives, local modules will not be skipped: " + err.Error()) + return localReplaceModules + } + for _, r := range modFile.Replace { + if modfile.IsDirectoryPath(r.New.Path) { + if localReplaceModules[r.Old.Path] == nil { + localReplaceModules[r.Old.Path] = map[string]bool{} + } + localReplaceModules[r.Old.Path][r.Old.Version] = true + } } + return localReplaceModules } func getGoVersionAsDependency() (*xrayUtils.GraphNode, error) { diff --git a/sca/bom/buildinfo/technologies/go/golang_test.go b/sca/bom/buildinfo/technologies/go/golang_test.go index d4dbc0dd5..83c7cf40c 100644 --- a/sca/bom/buildinfo/technologies/go/golang_test.go +++ b/sca/bom/buildinfo/technologies/go/golang_test.go @@ -12,10 +12,12 @@ import ( "github.com/jfrog/jfrog-cli-security/utils/techutils" "github.com/jfrog/build-info-go/utils" + "github.com/jfrog/gofrog/datastructures" "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-core/v2/utils/tests" "github.com/jfrog/jfrog-client-go/utils/io/fileutils" + xrayUtils "github.com/jfrog/jfrog-client-go/xray/services/utils" "github.com/stretchr/testify/assert" ) @@ -81,6 +83,174 @@ func removeTxtSuffix(txtFileName string) error { return fileutils.MoveFile(txtFileName, strings.TrimSuffix(txtFileName, ".txt")) } +// TestGetLocalReplaceModules: go.mod replaces example.com/localmod with a local directory. +// getLocalReplaceModules must report that module path as local, keyed by version ("" = unconditional). +func TestGetLocalReplaceModules(t *testing.T) { + _, cleanUp := technologies.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "go", "go-local-replace-project")) + defer cleanUp() + + assert.NoError(t, removeTxtSuffix("go.mod.txt")) + + currentDir, err := os.Getwd() + assert.NoError(t, err) + localReplaceModules := getLocalReplaceModules(currentDir) + assert.Equal(t, map[string]map[string]bool{"example.com/localmod": {"": true}}, localReplaceModules) +} + +// TestGetLocalReplaceModules_VersionPinned: a version-pinned replace only covers that one version - a +// different, real published version of the same path must still be probed, not silently skipped. +func TestGetLocalReplaceModules_VersionPinned(t *testing.T) { + tmpDir := t.TempDir() + goModContent := "module testVersionPinned\n\ngo 1.21\n\n" + + "require example.com/pinned v0.1.0\n\n" + + "replace example.com/pinned v0.1.0 => ./pinned\n" + assert.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(goModContent), 0644)) + + localReplaceModules := getLocalReplaceModules(tmpDir) + assert.Equal(t, map[string]map[string]bool{"example.com/pinned": {"v0.1.0": true}}, localReplaceModules) + + // The pinned version is local... + assert.True(t, isLocalReplaceModule("example.com/pinned:v0.1.0", localReplaceModules)) + // ...but a DIFFERENT version of the same path is a real, different published module - not local. + assert.False(t, isLocalReplaceModule("example.com/pinned:v0.2.0", localReplaceModules)) +} + +// TestGetLocalReplaceModules_MultiplePinnedVersionsSamePath: multiple version-pinned replaces for the same +// path are all captured; a version covered by none of them is still treated as real. +func TestGetLocalReplaceModules_MultiplePinnedVersionsSamePath(t *testing.T) { + tmpDir := t.TempDir() + goModContent := "module testMultiPinned\n\ngo 1.21\n\n" + + "require example.com/multi v0.2.0\n\n" + + "replace example.com/multi v0.1.0 => ./local-a\n\n" + + "replace example.com/multi v0.2.0 => ./local-b\n" + assert.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(goModContent), 0644)) + + localReplaceModules := getLocalReplaceModules(tmpDir) + assert.Equal(t, map[string]map[string]bool{"example.com/multi": {"v0.1.0": true, "v0.2.0": true}}, localReplaceModules) + + assert.True(t, isLocalReplaceModule("example.com/multi:v0.1.0", localReplaceModules)) + assert.True(t, isLocalReplaceModule("example.com/multi:v0.2.0", localReplaceModules)) + // A third version, covered by neither pinned replace, is a real published module - not local. + assert.False(t, isLocalReplaceModule("example.com/multi:v0.3.0", localReplaceModules)) +} + +// TestGetLocalReplaceModules_ModuleToModuleReplace: a replace pointing at another module (not a directory) +// is never local - it's a real published module and must still be probed. +func TestGetLocalReplaceModules_ModuleToModuleReplace(t *testing.T) { + tmpDir := t.TempDir() + goModContent := "module testModuleReplace\n\ngo 1.21\n\n" + + "require example.com/foo v1.0.0\n\n" + + "replace example.com/foo => example.com/bar v1.2.3\n" + assert.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(goModContent), 0644)) + + localReplaceModules := getLocalReplaceModules(tmpDir) + assert.Empty(t, localReplaceModules) + assert.False(t, isLocalReplaceModule("example.com/foo:v1.0.0", localReplaceModules)) +} + +// TestGetLocalReplaceModules_ErrorPaths: a missing or malformed go.mod must fail open (empty set, no panic). +func TestGetLocalReplaceModules_ErrorPaths(t *testing.T) { + t.Run("missing go.mod", func(t *testing.T) { + tmpDir := t.TempDir() // no go.mod written at all + assert.Empty(t, getLocalReplaceModules(tmpDir)) + }) + + t.Run("malformed go.mod", func(t *testing.T) { + tmpDir := t.TempDir() + assert.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("this is not { valid go.mod syntax"), 0644)) + assert.Empty(t, getLocalReplaceModules(tmpDir)) + }) +} + +// TestIsLocalReplaceModule: an unconditional replace matches any version; a pinned replace matches only that one. +func TestIsLocalReplaceModule(t *testing.T) { + tests := []struct { + name string + childName string + localReplaceModules map[string]map[string]bool + want bool + }{ + { + name: "unconditional replace matches any version", + childName: "example.com/mod:v9.9.9", + localReplaceModules: map[string]map[string]bool{"example.com/mod": {"": true}}, + want: true, + }, + { + name: "version-pinned replace matches the pinned version", + childName: "example.com/mod:v0.1.0", + localReplaceModules: map[string]map[string]bool{"example.com/mod": {"v0.1.0": true}}, + want: true, + }, + { + name: "version-pinned replace does NOT match a different version", + childName: "example.com/mod:v0.2.0", + localReplaceModules: map[string]map[string]bool{"example.com/mod": {"v0.1.0": true}}, + want: false, + }, + { + name: "unknown path", + childName: "example.com/other:v1.0.0", + localReplaceModules: map[string]map[string]bool{"example.com/mod": {"v0.1.0": true}}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isLocalReplaceModule(tt.childName, tt.localReplaceModules)) + }) + } +} + +// TestPopulateGoDependencyTree_LocalReplace: given a graph/list shaped like real 'go mod graph'/'go list' +// output for a local-replaced module, the tree must tag that module but leave its real dependency and an +// unrelated real dependency untouched. +func TestPopulateGoDependencyTree_LocalReplace(t *testing.T) { + dependenciesGraph := map[string][]string{ + "testGoLocalReplace": { + "example.com/localmod:v0.0.0", + "rsc.io/quote:v1.5.2", + }, + "example.com/localmod:v0.0.0": { + "golang.org/x/text:v0.3.3", + }, + "rsc.io/quote:v1.5.2": { + "rsc.io/sampler:v1.3.0", + }, + } + dependenciesList := map[string]bool{ + "example.com/localmod:v0.0.0": true, + "golang.org/x/text:v0.3.3": true, + "rsc.io/quote:v1.5.2": true, + "rsc.io/sampler:v1.3.0": true, + } + localReplaceModules := map[string]map[string]bool{"example.com/localmod": {"": true}} + + rootNode := &xrayUtils.GraphNode{Id: goPackageTypeIdentifier + "testGoLocalReplace", Nodes: []*xrayUtils.GraphNode{}} + uniqueDepsSet := datastructures.MakeSet[string]() + populateGoDependencyTree(rootNode, dependenciesGraph, dependenciesList, uniqueDepsSet, localReplaceModules) + + expectedUniqueDeps := []string{ + goPackageTypeIdentifier + "testGoLocalReplace", + goPackageTypeIdentifier + "example.com/localmod:v0.0.0" + LocalReplaceMarker, + goPackageTypeIdentifier + "golang.org/x/text:v0.3.3", + goPackageTypeIdentifier + "rsc.io/quote:v1.5.2", + goPackageTypeIdentifier + "rsc.io/sampler:v1.3.0", + } + assert.ElementsMatch(t, uniqueDepsSet.ToSlice(), expectedUniqueDeps, "First is actual, Second is Expected") + + // The locally-replaced module is tagged with the marker... + localReplaceNode := tests.GetAndAssertNode(t, rootNode.Nodes, "example.com/localmod:v0.0.0"+LocalReplaceMarker) + // ...but its own real, published dependency is still present, walked, and NOT marked. + assert.Len(t, localReplaceNode.Nodes, 1) + tests.GetAndAssertNode(t, localReplaceNode.Nodes, "golang.org/x/text:v0.3.3") + + // An unrelated real dependency (and its own transitive dependency) is completely untouched. + realDep := tests.GetAndAssertNode(t, rootNode.Nodes, "rsc.io/quote:v1.5.2") + assert.Len(t, realDep.Nodes, 1) + tests.GetAndAssertNode(t, realDep.Nodes, "rsc.io/sampler:v1.3.0") +} + func Test_handleCurationGoError(t *testing.T) { tests := []struct { diff --git a/tests/testdata/projects/package-managers/go/curation-project/go.mod b/tests/testdata/projects/package-managers/go/curation-project/go.mod index 309e9d797..a3e7620e0 100644 --- a/tests/testdata/projects/package-managers/go/curation-project/go.mod +++ b/tests/testdata/projects/package-managers/go/curation-project/go.mod @@ -5,5 +5,10 @@ go 1.20 require rsc.io/quote v1.5.2 require ( + golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c // indirect rsc.io/sampler v1.3.0 // indirect ) + +require example.com/localmod v0.0.0 + +replace example.com/localmod => ./localmod diff --git a/tests/testdata/projects/package-managers/go/curation-project/hello.go b/tests/testdata/projects/package-managers/go/curation-project/hello.go index 0a29866c4..4c374733e 100644 --- a/tests/testdata/projects/package-managers/go/curation-project/hello.go +++ b/tests/testdata/projects/package-managers/go/curation-project/hello.go @@ -3,9 +3,11 @@ package main import ( "fmt" + "example.com/localmod" "rsc.io/quote" ) func main() { fmt.Println(quote.Hello()) + fmt.Println(localmod.Hello()) } diff --git a/tests/testdata/projects/package-managers/go/curation-project/localmod/go.mod b/tests/testdata/projects/package-managers/go/curation-project/localmod/go.mod new file mode 100644 index 000000000..f8cdfbc40 --- /dev/null +++ b/tests/testdata/projects/package-managers/go/curation-project/localmod/go.mod @@ -0,0 +1,5 @@ +module example.com/localmod + +go 1.20 + +require rsc.io/sampler v1.3.0 diff --git a/tests/testdata/projects/package-managers/go/curation-project/localmod/localmod.go b/tests/testdata/projects/package-managers/go/curation-project/localmod/localmod.go new file mode 100644 index 000000000..321e49970 --- /dev/null +++ b/tests/testdata/projects/package-managers/go/curation-project/localmod/localmod.go @@ -0,0 +1,7 @@ +package localmod + +import "rsc.io/sampler" + +func Hello() string { + return sampler.Hello() +} diff --git a/tests/testdata/projects/package-managers/go/go-local-replace-project/go.mod.txt b/tests/testdata/projects/package-managers/go/go-local-replace-project/go.mod.txt new file mode 100644 index 000000000..fd4d88e36 --- /dev/null +++ b/tests/testdata/projects/package-managers/go/go-local-replace-project/go.mod.txt @@ -0,0 +1,10 @@ +module testGoLocalReplace + +go 1.16 + +require ( + example.com/localmod v0.0.0 + rsc.io/quote v1.5.2 +) + +replace example.com/localmod => ./localmod