diff options
Diffstat (limited to 'gopls/internal/lsp/mod/diagnostics.go')
-rw-r--r-- | gopls/internal/lsp/mod/diagnostics.go | 561 |
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) +} |