Skip to content

Commit c16174c

Browse files
feat(linters): add errstringmatch linter (#33117)
1 parent ea4def2 commit c16174c

4 files changed

Lines changed: 181 additions & 0 deletions

File tree

cmd/linters/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"golang.org/x/tools/go/analysis/multichecker"
1818

1919
"github.com/github/gh-aw/pkg/linters/ctxbackground"
20+
"github.com/github/gh-aw/pkg/linters/errstringmatch"
2021
"github.com/github/gh-aw/pkg/linters/excessivefuncparams"
2122
"github.com/github/gh-aw/pkg/linters/largefunc"
2223
"github.com/github/gh-aw/pkg/linters/osexitinlibrary"
@@ -27,6 +28,7 @@ import (
2728
func main() {
2829
multichecker.Main(
2930
ctxbackground.Analyzer,
31+
errstringmatch.Analyzer,
3032
excessivefuncparams.Analyzer,
3133
largefunc.Analyzer,
3234
osexitinlibrary.Analyzer,
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Package errstringmatch implements a Go analysis linter that flags
2+
// calls to strings.Contains(err.Error(), "literal") that perform brittle
3+
// substring matching on error messages instead of using errors.Is or errors.As.
4+
package errstringmatch
5+
6+
import (
7+
"go/ast"
8+
"go/token"
9+
"go/types"
10+
11+
"golang.org/x/tools/go/analysis"
12+
"golang.org/x/tools/go/analysis/passes/inspect"
13+
"golang.org/x/tools/go/ast/inspector"
14+
)
15+
16+
// Analyzer is the err-string-match analysis pass.
17+
var Analyzer = &analysis.Analyzer{
18+
Name: "errstringmatch",
19+
Doc: "reports strings.Contains(err.Error(), \"...\") calls that perform brittle substring matching on error messages",
20+
URL: "https://github.com/github/gh-aw/tree/main/pkg/linters/errstringmatch",
21+
Requires: []*analysis.Analyzer{inspect.Analyzer},
22+
Run: run,
23+
}
24+
25+
func run(pass *analysis.Pass) (any, error) {
26+
insp := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
27+
28+
nodeFilter := []ast.Node{
29+
(*ast.CallExpr)(nil),
30+
}
31+
32+
insp.Preorder(nodeFilter, func(n ast.Node) {
33+
outer, ok := n.(*ast.CallExpr)
34+
if !ok {
35+
return
36+
}
37+
38+
// Match strings.Contains(X, Y)
39+
if !isStringsContains(outer) {
40+
return
41+
}
42+
if len(outer.Args) != 2 {
43+
return
44+
}
45+
46+
// First arg must be a call to err.Error()
47+
if !isErrDotError(pass, outer.Args[0]) {
48+
return
49+
}
50+
51+
// Second arg must be a string literal (or at least a string type)
52+
if !isStringLiteral(pass, outer.Args[1]) {
53+
return
54+
}
55+
56+
pass.Reportf(outer.Pos(), "avoid strings.Contains(err.Error(), ...) — use errors.Is, errors.As, or a sentinel error instead")
57+
})
58+
59+
return nil, nil
60+
}
61+
62+
// isStringsContains returns true for strings.Contains(...) call expressions.
63+
func isStringsContains(call *ast.CallExpr) bool {
64+
sel, ok := call.Fun.(*ast.SelectorExpr)
65+
if !ok {
66+
return false
67+
}
68+
ident, ok := sel.X.(*ast.Ident)
69+
if !ok {
70+
return false
71+
}
72+
return ident.Name == "strings" && sel.Sel.Name == "Contains"
73+
}
74+
75+
// isErrDotError returns true when expr is a method call of the form <expr>.Error()
76+
// where the receiver implements the error interface.
77+
func isErrDotError(pass *analysis.Pass, expr ast.Expr) bool {
78+
call, ok := expr.(*ast.CallExpr)
79+
if !ok {
80+
return false
81+
}
82+
sel, ok := call.Fun.(*ast.SelectorExpr)
83+
if !ok {
84+
return false
85+
}
86+
if sel.Sel.Name != "Error" {
87+
return false
88+
}
89+
if len(call.Args) != 0 {
90+
return false
91+
}
92+
// Check that the receiver implements the error interface.
93+
t := pass.TypesInfo.TypeOf(sel.X)
94+
if t == nil {
95+
return false
96+
}
97+
return implementsError(pass, t)
98+
}
99+
100+
// implementsError reports whether t implements the built-in error interface.
101+
func implementsError(pass *analysis.Pass, t types.Type) bool {
102+
errIface := pass.Pkg.Scope().Lookup("error")
103+
if errIface == nil {
104+
// Look up the universe scope.
105+
obj := types.Universe.Lookup("error")
106+
if obj == nil {
107+
return false
108+
}
109+
iface, ok := obj.Type().Underlying().(*types.Interface)
110+
if !ok {
111+
return false
112+
}
113+
return types.Implements(t, iface) || types.Implements(types.NewPointer(t), iface)
114+
}
115+
iface, ok := errIface.Type().Underlying().(*types.Interface)
116+
if !ok {
117+
return false
118+
}
119+
return types.Implements(t, iface) || types.Implements(types.NewPointer(t), iface)
120+
}
121+
122+
// isStringLiteral returns true when expr is a string literal or untyped string constant.
123+
func isStringLiteral(pass *analysis.Pass, expr ast.Expr) bool {
124+
lit, ok := expr.(*ast.BasicLit)
125+
if ok && lit.Kind == token.STRING {
126+
return true
127+
}
128+
// Also accept typed/untyped string constants (e.g. a const identifier).
129+
t := pass.TypesInfo.TypeOf(expr)
130+
if t == nil {
131+
return false
132+
}
133+
basic, ok := t.Underlying().(*types.Basic)
134+
return ok && basic.Kind() == types.String
135+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//go:build !integration
2+
3+
package errstringmatch_test
4+
5+
import (
6+
"testing"
7+
8+
"golang.org/x/tools/go/analysis/analysistest"
9+
10+
"github.com/github/gh-aw/pkg/linters/errstringmatch"
11+
)
12+
13+
func TestErrStringMatch(t *testing.T) {
14+
testdata := analysistest.TestData()
15+
analysistest.Run(t, testdata, errstringmatch.Analyzer, "errstringmatch")
16+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package errstringmatch
2+
3+
import (
4+
"errors"
5+
"strings"
6+
)
7+
8+
var errNotFound = errors.New("not found")
9+
10+
// flagged: strings.Contains on err.Error() with a string literal
11+
func checkError(err error) bool {
12+
return strings.Contains(err.Error(), "not found") // want `avoid strings\.Contains\(err\.Error\(\)`
13+
}
14+
15+
// flagged: same pattern with a different variable name
16+
func checkPermission(e error) bool {
17+
return strings.Contains(e.Error(), "403") // want `avoid strings\.Contains\(err\.Error\(\)`
18+
}
19+
20+
// not flagged: using errors.Is
21+
func checkErrorSafe(err error) bool {
22+
return errors.Is(err, errNotFound)
23+
}
24+
25+
// not flagged: strings.Contains on a plain string, not err.Error()
26+
func checkString(s string) bool {
27+
return strings.Contains(s, "prefix")
28+
}

0 commit comments

Comments
 (0)