aboutsummaryrefslogtreecommitdiff
path: root/gopls/internal/lsp/mod/diagnostics.go
diff options
context:
space:
mode:
Diffstat (limited to 'gopls/internal/lsp/mod/diagnostics.go')
-rw-r--r--gopls/internal/lsp/mod/diagnostics.go561
1 files changed, 561 insertions, 0 deletions
diff --git a/gopls/internal/lsp/mod/diagnostics.go b/gopls/internal/lsp/mod/diagnostics.go
new file mode 100644
index 000000000..746a14e91
--- /dev/null
+++ b/gopls/internal/lsp/mod/diagnostics.go
@@ -0,0 +1,561 @@
+// Copyright 2019 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package mod provides core features related to go.mod file
+// handling for use by Go editors and tools.
+package mod
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+
+ "golang.org/x/mod/modfile"
+ "golang.org/x/mod/semver"
+ "golang.org/x/tools/gopls/internal/govulncheck"
+ "golang.org/x/tools/gopls/internal/lsp/command"
+ "golang.org/x/tools/gopls/internal/lsp/protocol"
+ "golang.org/x/tools/gopls/internal/lsp/source"
+ "golang.org/x/tools/gopls/internal/span"
+ "golang.org/x/tools/internal/event"
+ "golang.org/x/vuln/osv"
+)
+
+// Diagnostics returns diagnostics for the modules in the workspace.
+//
+// It waits for completion of type-checking of all active packages.
+func Diagnostics(ctx context.Context, snapshot source.Snapshot) (map[span.URI][]*source.Diagnostic, error) {
+ ctx, done := event.Start(ctx, "mod.Diagnostics", source.SnapshotLabels(snapshot)...)
+ defer done()
+
+ return collectDiagnostics(ctx, snapshot, ModDiagnostics)
+}
+
+// UpgradeDiagnostics returns upgrade diagnostics for the modules in the
+// workspace with known upgrades.
+func UpgradeDiagnostics(ctx context.Context, snapshot source.Snapshot) (map[span.URI][]*source.Diagnostic, error) {
+ ctx, done := event.Start(ctx, "mod.UpgradeDiagnostics", source.SnapshotLabels(snapshot)...)
+ defer done()
+
+ return collectDiagnostics(ctx, snapshot, ModUpgradeDiagnostics)
+}
+
+// VulnerabilityDiagnostics returns vulnerability diagnostics for the active modules in the
+// workspace with known vulnerabilities.
+func VulnerabilityDiagnostics(ctx context.Context, snapshot source.Snapshot) (map[span.URI][]*source.Diagnostic, error) {
+ ctx, done := event.Start(ctx, "mod.VulnerabilityDiagnostics", source.SnapshotLabels(snapshot)...)
+ defer done()
+
+ return collectDiagnostics(ctx, snapshot, ModVulnerabilityDiagnostics)
+}
+
+func collectDiagnostics(ctx context.Context, snapshot source.Snapshot, diagFn func(context.Context, source.Snapshot, source.FileHandle) ([]*source.Diagnostic, error)) (map[span.URI][]*source.Diagnostic, error) {
+ reports := make(map[span.URI][]*source.Diagnostic)
+ for _, uri := range snapshot.ModFiles() {
+ fh, err := snapshot.GetFile(ctx, uri)
+ if err != nil {
+ return nil, err
+ }
+ reports[fh.URI()] = []*source.Diagnostic{}
+ diagnostics, err := diagFn(ctx, snapshot, fh)
+ if err != nil {
+ return nil, err
+ }
+ for _, d := range diagnostics {
+ fh, err := snapshot.GetFile(ctx, d.URI)
+ if err != nil {
+ return nil, err
+ }
+ reports[fh.URI()] = append(reports[fh.URI()], d)
+ }
+ }
+ return reports, nil
+}
+
+// ModDiagnostics waits for completion of type-checking of all active
+// packages, then returns diagnostics from diagnosing the packages in
+// the workspace and from tidying the go.mod file.
+func ModDiagnostics(ctx context.Context, snapshot source.Snapshot, fh source.FileHandle) (diagnostics []*source.Diagnostic, err error) {
+ pm, err := snapshot.ParseMod(ctx, fh)
+ if err != nil {
+ if pm == nil || len(pm.ParseErrors) == 0 {
+ return nil, err
+ }
+ return pm.ParseErrors, nil
+ }
+
+ // Packages in the workspace can contribute diagnostics to go.mod files.
+ // TODO(rfindley): Try to avoid type checking all packages in the workspace here,
+ // for every go.mod file. If gc_details is enabled, it looks like this could lead to extra
+ // go command invocations (as gc details is not memoized).
+ active, err := snapshot.ActiveMetadata(ctx)
+ if err != nil && !source.IsNonFatalGoModError(err) {
+ event.Error(ctx, fmt.Sprintf("workspace packages: diagnosing %s", pm.URI), err)
+ }
+ if err == nil {
+ // Note: the call to PackageDiagnostics below may be the first operation
+ // after the initial metadata load, and therefore result in type-checking
+ // or loading many packages.
+ ids := make([]source.PackageID, len(active))
+ for i, meta := range active {
+ ids[i] = meta.ID
+ }
+ diags, err := snapshot.PackageDiagnostics(ctx, ids...)
+ if err != nil {
+ return nil, err
+ }
+ diagnostics = append(diagnostics, diags[fh.URI()]...)
+ }
+
+ tidied, err := snapshot.ModTidy(ctx, pm)
+ if err != nil && !source.IsNonFatalGoModError(err) {
+ event.Error(ctx, fmt.Sprintf("tidy: diagnosing %s", pm.URI), err)
+ }
+ if err == nil {
+ for _, d := range tidied.Diagnostics {
+ if d.URI != fh.URI() {
+ continue
+ }
+ diagnostics = append(diagnostics, d)
+ }
+ }
+ return diagnostics, nil
+}
+
+// ModUpgradeDiagnostics adds upgrade quick fixes for individual modules if the upgrades
+// are recorded in the view.
+func ModUpgradeDiagnostics(ctx context.Context, snapshot source.Snapshot, fh source.FileHandle) (upgradeDiagnostics []*source.Diagnostic, err error) {
+ pm, err := snapshot.ParseMod(ctx, fh)
+ if err != nil {
+ // Don't return an error if there are parse error diagnostics to be shown, but also do not
+ // continue since we won't be able to show the upgrade diagnostics.
+ if pm != nil && len(pm.ParseErrors) != 0 {
+ return nil, nil
+ }
+ return nil, err
+ }
+
+ upgrades := snapshot.View().ModuleUpgrades(fh.URI())
+ for _, req := range pm.File.Require {
+ ver, ok := upgrades[req.Mod.Path]
+ if !ok || req.Mod.Version == ver {
+ continue
+ }
+ rng, err := pm.Mapper.OffsetRange(req.Syntax.Start.Byte, req.Syntax.End.Byte)
+ if err != nil {
+ return nil, err
+ }
+ // Upgrade to the exact version we offer the user, not the most recent.
+ title := fmt.Sprintf("%s%v", upgradeCodeActionPrefix, ver)
+ cmd, err := command.NewUpgradeDependencyCommand(title, command.DependencyArgs{
+ URI: protocol.URIFromSpanURI(fh.URI()),
+ AddRequire: false,
+ GoCmdArgs: []string{req.Mod.Path + "@" + ver},
+ })
+ if err != nil {
+ return nil, err
+ }
+ upgradeDiagnostics = append(upgradeDiagnostics, &source.Diagnostic{
+ URI: fh.URI(),
+ Range: rng,
+ Severity: protocol.SeverityInformation,
+ Source: source.UpgradeNotification,
+ Message: fmt.Sprintf("%v can be upgraded", req.Mod.Path),
+ SuggestedFixes: []source.SuggestedFix{source.SuggestedFixFromCommand(cmd, protocol.QuickFix)},
+ })
+ }
+
+ return upgradeDiagnostics, nil
+}
+
+const upgradeCodeActionPrefix = "Upgrade to "
+
+// ModVulnerabilityDiagnostics adds diagnostics for vulnerabilities in individual modules
+// if the vulnerability is recorded in the view.
+func ModVulnerabilityDiagnostics(ctx context.Context, snapshot source.Snapshot, fh source.FileHandle) (vulnDiagnostics []*source.Diagnostic, err error) {
+ pm, err := snapshot.ParseMod(ctx, fh)
+ if err != nil {
+ // Don't return an error if there are parse error diagnostics to be shown, but also do not
+ // continue since we won't be able to show the vulnerability diagnostics.
+ if pm != nil && len(pm.ParseErrors) != 0 {
+ return nil, nil
+ }
+ return nil, err
+ }
+
+ diagSource := source.Govulncheck
+ vs := snapshot.View().Vulnerabilities(fh.URI())[fh.URI()]
+ if vs == nil && snapshot.View().Options().Vulncheck == source.ModeVulncheckImports {
+ vs, err = snapshot.ModVuln(ctx, fh.URI())
+ if err != nil {
+ return nil, err
+ }
+ diagSource = source.Vulncheck
+ }
+ if vs == nil || len(vs.Vulns) == 0 {
+ return nil, nil
+ }
+
+ suggestRunOrResetGovulncheck, err := suggestGovulncheckAction(diagSource == source.Govulncheck, fh.URI())
+ if err != nil {
+ // must not happen
+ return nil, err // TODO: bug report
+ }
+ type modVuln struct {
+ mod *govulncheck.Module
+ vuln *govulncheck.Vuln
+ }
+ vulnsByModule := make(map[string][]modVuln)
+ for _, vuln := range vs.Vulns {
+ for _, mod := range vuln.Modules {
+ vulnsByModule[mod.Path] = append(vulnsByModule[mod.Path], modVuln{mod, vuln})
+ }
+ }
+
+ for _, req := range pm.File.Require {
+ vulns := vulnsByModule[req.Mod.Path]
+ if len(vulns) == 0 {
+ continue
+ }
+ // note: req.Syntax is the line corresponding to 'require', which means
+ // req.Syntax.Start can point to the beginning of the "require" keyword
+ // for a single line require (e.g. "require golang.org/x/mod v0.0.0").
+ start := req.Syntax.Start.Byte
+ if len(req.Syntax.Token) == 3 {
+ start += len("require ")
+ }
+ rng, err := pm.Mapper.OffsetRange(start, req.Syntax.End.Byte)
+ if err != nil {
+ return nil, err
+ }
+ // Map affecting vulns to 'warning' level diagnostics,
+ // others to 'info' level diagnostics.
+ // Fixes will include only the upgrades for warning level diagnostics.
+ var warningFixes, infoFixes []source.SuggestedFix
+ var warning, info []string
+ var relatedInfo []protocol.DiagnosticRelatedInformation
+ for _, mv := range vulns {
+ mod, vuln := mv.mod, mv.vuln
+ // It is possible that the source code was changed since the last
+ // govulncheck run and information in the `vulns` info is stale.
+ // For example, imagine that a user is in the middle of updating
+ // problematic modules detected by the govulncheck run by applying
+ // quick fixes. Stale diagnostics can be confusing and prevent the
+ // user from quickly locating the next module to fix.
+ // Ideally we should rerun the analysis with the updated module
+ // dependencies or any other code changes, but we are not yet
+ // in the position of automatically triggering the analysis
+ // (govulncheck can take a while). We also don't know exactly what
+ // part of source code was changed since `vulns` was computed.
+ // As a heuristic, we assume that a user upgrades the affecting
+ // module to the version with the fix or the latest one, and if the
+ // version in the require statement is equal to or higher than the
+ // fixed version, skip generating a diagnostic about the vulnerability.
+ // Eventually, the user has to rerun govulncheck.
+ if mod.FixedVersion != "" && semver.IsValid(req.Mod.Version) && semver.Compare(mod.FixedVersion, req.Mod.Version) <= 0 {
+ continue
+ }
+ if !vuln.IsCalled() {
+ info = append(info, vuln.OSV.ID)
+ } else {
+ warning = append(warning, vuln.OSV.ID)
+ relatedInfo = append(relatedInfo, listRelatedInfo(ctx, snapshot, vuln)...)
+ }
+ // Upgrade to the exact version we offer the user, not the most recent.
+ if fixedVersion := mod.FixedVersion; semver.IsValid(fixedVersion) && semver.Compare(req.Mod.Version, fixedVersion) < 0 {
+ cmd, err := getUpgradeCodeAction(fh, req, fixedVersion)
+ if err != nil {
+ return nil, err // TODO: bug report
+ }
+ sf := source.SuggestedFixFromCommand(cmd, protocol.QuickFix)
+ if !vuln.IsCalled() {
+ infoFixes = append(infoFixes, sf)
+ } else {
+ warningFixes = append(warningFixes, sf)
+ }
+ }
+ }
+
+ if len(warning) == 0 && len(info) == 0 {
+ continue
+ }
+ // Add an upgrade for module@latest.
+ // TODO(suzmue): verify if latest is the same as fixedVersion.
+ latest, err := getUpgradeCodeAction(fh, req, "latest")
+ if err != nil {
+ return nil, err // TODO: bug report
+ }
+ sf := source.SuggestedFixFromCommand(latest, protocol.QuickFix)
+ if len(warningFixes) > 0 {
+ warningFixes = append(warningFixes, sf)
+ }
+ if len(infoFixes) > 0 {
+ infoFixes = append(infoFixes, sf)
+ }
+
+ sort.Strings(warning)
+ sort.Strings(info)
+
+ if len(warning) > 0 {
+ warningFixes = append(warningFixes, suggestRunOrResetGovulncheck)
+ vulnDiagnostics = append(vulnDiagnostics, &source.Diagnostic{
+ URI: fh.URI(),
+ Range: rng,
+ Severity: protocol.SeverityWarning,
+ Source: diagSource,
+ Message: getVulnMessage(req.Mod.Path, warning, true, diagSource == source.Govulncheck),
+ SuggestedFixes: warningFixes,
+ Related: relatedInfo,
+ })
+ }
+ if len(info) > 0 {
+ infoFixes = append(infoFixes, suggestRunOrResetGovulncheck)
+ vulnDiagnostics = append(vulnDiagnostics, &source.Diagnostic{
+ URI: fh.URI(),
+ Range: rng,
+ Severity: protocol.SeverityInformation,
+ Source: diagSource,
+ Message: getVulnMessage(req.Mod.Path, info, false, diagSource == source.Govulncheck),
+ SuggestedFixes: infoFixes,
+ Related: relatedInfo,
+ })
+ }
+ }
+
+ // TODO(hyangah): place this diagnostic on the `go` directive or `toolchain` directive
+ // after https://go.dev/issue/57001.
+ const diagnoseStdLib = false
+ if diagnoseStdLib {
+ // Add standard library vulnerabilities.
+ stdlibVulns := vulnsByModule["stdlib"]
+ if len(stdlibVulns) == 0 {
+ return vulnDiagnostics, nil
+ }
+
+ // Put the standard library diagnostic on the module declaration.
+ rng, err := pm.Mapper.OffsetRange(pm.File.Module.Syntax.Start.Byte, pm.File.Module.Syntax.End.Byte)
+ if err != nil {
+ return vulnDiagnostics, nil // TODO: bug report
+ }
+
+ stdlib := stdlibVulns[0].mod.FoundVersion
+ var warning, info []string
+ var relatedInfo []protocol.DiagnosticRelatedInformation
+ for _, mv := range stdlibVulns {
+ vuln := mv.vuln
+ stdlib = mv.mod.FoundVersion
+ if !vuln.IsCalled() {
+ info = append(info, vuln.OSV.ID)
+ } else {
+ warning = append(warning, vuln.OSV.ID)
+ relatedInfo = append(relatedInfo, listRelatedInfo(ctx, snapshot, vuln)...)
+ }
+ }
+ if len(warning) > 0 {
+ fixes := []source.SuggestedFix{suggestRunOrResetGovulncheck}
+ vulnDiagnostics = append(vulnDiagnostics, &source.Diagnostic{
+ URI: fh.URI(),
+ Range: rng,
+ Severity: protocol.SeverityWarning,
+ Source: diagSource,
+ Message: getVulnMessage(stdlib, warning, true, diagSource == source.Govulncheck),
+ SuggestedFixes: fixes,
+ Related: relatedInfo,
+ })
+ }
+ if len(info) > 0 {
+ fixes := []source.SuggestedFix{suggestRunOrResetGovulncheck}
+ vulnDiagnostics = append(vulnDiagnostics, &source.Diagnostic{
+ URI: fh.URI(),
+ Range: rng,
+ Severity: protocol.SeverityInformation,
+ Source: diagSource,
+ Message: getVulnMessage(stdlib, info, false, diagSource == source.Govulncheck),
+ SuggestedFixes: fixes,
+ Related: relatedInfo,
+ })
+ }
+ }
+
+ return vulnDiagnostics, nil
+}
+
+// suggestGovulncheckAction returns a code action that suggests either run govulncheck
+// for more accurate investigation (if the present vulncheck diagnostics are based on
+// analysis less accurate than govulncheck) or reset the existing govulncheck result
+// (if the present vulncheck diagnostics are already based on govulncheck run).
+func suggestGovulncheckAction(fromGovulncheck bool, uri span.URI) (source.SuggestedFix, error) {
+ if fromGovulncheck {
+ resetVulncheck, err := command.NewResetGoModDiagnosticsCommand("Reset govulncheck result", command.ResetGoModDiagnosticsArgs{
+ URIArg: command.URIArg{URI: protocol.DocumentURI(uri)},
+ DiagnosticSource: string(source.Govulncheck),
+ })
+ if err != nil {
+ return source.SuggestedFix{}, err
+ }
+ return source.SuggestedFixFromCommand(resetVulncheck, protocol.QuickFix), nil
+ }
+ vulncheck, err := command.NewRunGovulncheckCommand("Run govulncheck to verify", command.VulncheckArgs{
+ URI: protocol.DocumentURI(uri),
+ Pattern: "./...",
+ })
+ if err != nil {
+ return source.SuggestedFix{}, err
+ }
+ return source.SuggestedFixFromCommand(vulncheck, protocol.QuickFix), nil
+}
+
+func getVulnMessage(mod string, vulns []string, used, fromGovulncheck bool) string {
+ var b strings.Builder
+ if used {
+ switch len(vulns) {
+ case 1:
+ fmt.Fprintf(&b, "%v has a vulnerability used in the code: %v.", mod, vulns[0])
+ default:
+ fmt.Fprintf(&b, "%v has vulnerabilities used in the code: %v.", mod, strings.Join(vulns, ", "))
+ }
+ } else {
+ if fromGovulncheck {
+ switch len(vulns) {
+ case 1:
+ fmt.Fprintf(&b, "%v has a vulnerability %v that is not used in the code.", mod, vulns[0])
+ default:
+ fmt.Fprintf(&b, "%v has known vulnerabilities %v that are not used in the code.", mod, strings.Join(vulns, ", "))
+ }
+ } else {
+ switch len(vulns) {
+ case 1:
+ fmt.Fprintf(&b, "%v has a vulnerability %v.", mod, vulns[0])
+ default:
+ fmt.Fprintf(&b, "%v has known vulnerabilities %v.", mod, strings.Join(vulns, ", "))
+ }
+ }
+ }
+ return b.String()
+}
+
+func listRelatedInfo(ctx context.Context, snapshot source.Snapshot, vuln *govulncheck.Vuln) []protocol.DiagnosticRelatedInformation {
+ var ri []protocol.DiagnosticRelatedInformation
+ for _, m := range vuln.Modules {
+ for _, p := range m.Packages {
+ for _, c := range p.CallStacks {
+ if len(c.Frames) == 0 {
+ continue
+ }
+ entry := c.Frames[0]
+ pos := entry.Position
+ if pos.Filename == "" {
+ continue // token.Position Filename is an optional field.
+ }
+ uri := span.URIFromPath(pos.Filename)
+ startPos := protocol.Position{
+ Line: uint32(pos.Line) - 1,
+ // We need to read the file contents to precisesly map
+ // token.Position (pos) to the UTF16-based column offset
+ // protocol.Position requires. That can be expensive.
+ // We need this related info to just help users to open
+ // the entry points of the callstack and once the file is
+ // open, we will compute the precise location based on the
+ // open file contents. So, use the beginning of the line
+ // as the position here instead of precise UTF16-based
+ // position computation.
+ Character: 0,
+ }
+ ri = append(ri, protocol.DiagnosticRelatedInformation{
+ Location: protocol.Location{
+ URI: protocol.URIFromSpanURI(uri),
+ Range: protocol.Range{
+ Start: startPos,
+ End: startPos,
+ },
+ },
+ Message: fmt.Sprintf("[%v] %v -> %v.%v", vuln.OSV.ID, entry.Name(), p.Path, c.Symbol),
+ })
+ }
+ }
+ }
+ return ri
+}
+
+func formatMessage(v *govulncheck.Vuln) string {
+ details := []byte(v.OSV.Details)
+ // Remove any new lines that are not preceded or followed by a new line.
+ for i, r := range details {
+ if r == '\n' && i > 0 && details[i-1] != '\n' && i+1 < len(details) && details[i+1] != '\n' {
+ details[i] = ' '
+ }
+ }
+ return strings.TrimSpace(strings.Replace(string(details), "\n\n", "\n\n ", -1))
+}
+
+// href returns the url for the vulnerability information.
+// Eventually we should retrieve the url embedded in the osv.Entry.
+// While vuln.go.dev is under development, this always returns
+// the page in pkg.go.dev.
+func href(vuln *osv.Entry) string {
+ return fmt.Sprintf("https://pkg.go.dev/vuln/%s", vuln.ID)
+}
+
+func getUpgradeCodeAction(fh source.FileHandle, req *modfile.Require, version string) (protocol.Command, error) {
+ cmd, err := command.NewUpgradeDependencyCommand(upgradeTitle(version), command.DependencyArgs{
+ URI: protocol.URIFromSpanURI(fh.URI()),
+ AddRequire: false,
+ GoCmdArgs: []string{req.Mod.Path + "@" + version},
+ })
+ if err != nil {
+ return protocol.Command{}, err
+ }
+ return cmd, nil
+}
+
+func upgradeTitle(fixedVersion string) string {
+ title := fmt.Sprintf("%s%v", upgradeCodeActionPrefix, fixedVersion)
+ return title
+}
+
+// SelectUpgradeCodeActions takes a list of code actions for a required module
+// and returns a more selective list of upgrade code actions,
+// where the code actions have been deduped. Code actions unrelated to upgrade
+// are deduplicated by the name.
+func SelectUpgradeCodeActions(actions []protocol.CodeAction) []protocol.CodeAction {
+ if len(actions) <= 1 {
+ return actions // return early if no sorting necessary
+ }
+ var versionedUpgrade, latestUpgrade, resetAction protocol.CodeAction
+ var chosenVersionedUpgrade string
+ var selected []protocol.CodeAction
+
+ seen := make(map[string]bool)
+
+ for _, action := range actions {
+ if strings.HasPrefix(action.Title, upgradeCodeActionPrefix) {
+ if v := getUpgradeVersion(action); v == "latest" && latestUpgrade.Title == "" {
+ latestUpgrade = action
+ } else if versionedUpgrade.Title == "" || semver.Compare(v, chosenVersionedUpgrade) > 0 {
+ chosenVersionedUpgrade = v
+ versionedUpgrade = action
+ }
+ } else if strings.HasPrefix(action.Title, "Reset govulncheck") {
+ resetAction = action
+ } else if !seen[action.Command.Title] {
+ seen[action.Command.Title] = true
+ selected = append(selected, action)
+ }
+ }
+ if versionedUpgrade.Title != "" {
+ selected = append(selected, versionedUpgrade)
+ }
+ if latestUpgrade.Title != "" {
+ selected = append(selected, latestUpgrade)
+ }
+ if resetAction.Title != "" {
+ selected = append(selected, resetAction)
+ }
+ return selected
+}
+
+func getUpgradeVersion(p protocol.CodeAction) string {
+ return strings.TrimPrefix(p.Title, upgradeCodeActionPrefix)
+}