Some checks failed
check / check (push) Superseded by a newer commit; never tested
261 lines
6.1 KiB
Go
261 lines
6.1 KiB
Go
package gormlog_test
|
|
|
|
import (
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// minNonTestFiles guards the walk below against passing because it
|
|
// found nothing to look at. The tree held 60 non-test .go files when
|
|
// this was written.
|
|
const minNonTestFiles = 40
|
|
|
|
// isRowProducer reports whether name is a method that returns a
|
|
// database/sql row handle. GORM's Row and Rows return *sql.Row and
|
|
// *sql.Rows, so Scan on the result of one of them is database/sql's
|
|
// Scan and never (*gorm.DB).Scan.
|
|
func isRowProducer(name string) bool {
|
|
switch name {
|
|
case "Row", "Rows", "QueryRow", "QueryRowContext":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// receiverIsRowHandle reports whether x is syntactically a call to a
|
|
// row producer, which is the only receiver form this check accepts for
|
|
// a Scan.
|
|
func receiverIsRowHandle(x ast.Expr) bool {
|
|
call, ok := x.(*ast.CallExpr)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
return isRowProducer(sel.Sel.Name)
|
|
}
|
|
|
|
// unguardedScans returns the position of every Scan call in file whose
|
|
// receiver is not a row handle. It fails closed: a receiver it cannot
|
|
// resolve syntactically — a local variable, a struct field — is
|
|
// reported rather than assumed safe.
|
|
func unguardedScans(
|
|
fset *token.FileSet, file *ast.File,
|
|
) []token.Position {
|
|
var found []token.Position
|
|
|
|
ast.Inspect(file, func(n ast.Node) bool {
|
|
call, ok := n.(*ast.CallExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
|
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
|
if !ok || sel.Sel.Name != "Scan" {
|
|
return true
|
|
}
|
|
|
|
if !receiverIsRowHandle(sel.X) {
|
|
found = append(found, fset.Position(sel.Sel.Pos()))
|
|
}
|
|
|
|
return true
|
|
})
|
|
|
|
return found
|
|
}
|
|
|
|
// moduleRoot walks up from the working directory to the directory
|
|
// holding go.mod.
|
|
func moduleRoot(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
dir, err := os.Getwd()
|
|
require.NoError(t, err)
|
|
|
|
for {
|
|
_, statErr := os.Stat(filepath.Join(dir, "go.mod"))
|
|
if statErr == nil {
|
|
return dir
|
|
}
|
|
|
|
parent := filepath.Dir(dir)
|
|
require.NotEqual(t, parent, dir, "no go.mod above %s", dir)
|
|
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
// skipDir reports whether a directory holds no source this check
|
|
// governs.
|
|
func skipDir(name string) bool {
|
|
switch name {
|
|
case ".git", "bin", "node_modules", "testdata":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// walkNonTestGo parses every non-test .go file under root and returns
|
|
// how many it parsed along with every unguarded Scan it found.
|
|
func walkNonTestGo(t *testing.T, root string) (int, []string) {
|
|
t.Helper()
|
|
|
|
var (
|
|
parsed int
|
|
hits []string
|
|
)
|
|
|
|
fset := token.NewFileSet()
|
|
|
|
require.NoError(t, filepath.WalkDir(
|
|
root,
|
|
func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if d.IsDir() {
|
|
if skipDir(d.Name()) {
|
|
return fs.SkipDir
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
if !isNonTestGo(d.Name()) {
|
|
return nil
|
|
}
|
|
|
|
file, err := parser.ParseFile(fset, path, nil, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
parsed++
|
|
|
|
for _, pos := range unguardedScans(fset, file) {
|
|
hits = append(hits, relPosition(root, pos))
|
|
}
|
|
|
|
return nil
|
|
},
|
|
))
|
|
|
|
return parsed, hits
|
|
}
|
|
|
|
// isNonTestGo reports whether a file name is Go source this check
|
|
// governs.
|
|
func isNonTestGo(name string) bool {
|
|
return strings.HasSuffix(name, ".go") &&
|
|
!strings.HasSuffix(name, "_test.go")
|
|
}
|
|
|
|
// relPosition renders pos with its path relative to root, so a failure
|
|
// names the file the way the repository does.
|
|
func relPosition(root string, pos token.Position) string {
|
|
name := pos.Filename
|
|
|
|
rel, err := filepath.Rel(root, name)
|
|
if err == nil {
|
|
name = rel
|
|
}
|
|
|
|
return fmt.Sprintf("%s:%d:%d", name, pos.Line, pos.Column)
|
|
}
|
|
|
|
// TestGormScanIsNeverCalledOutsideTests keeps (*gorm.DB).Scan out of
|
|
// non-test code.
|
|
//
|
|
// It is the one statement path (*Logger).ParamsFilter does not reach:
|
|
// Scan swaps GORM's own trace recorder in for the adapter, and that
|
|
// recorder does not implement gorm.ParamsFilter, so the statement is
|
|
// logged with its values interpolated. The package comment states the
|
|
// limit; this fails when someone adds a call site anyway.
|
|
//
|
|
// The current tree has one caller, internal/database/database_test.go,
|
|
// which this check does not govern: it is test-only and its SELECT 1
|
|
// binds nothing.
|
|
func TestGormScanIsNeverCalledOutsideTests(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
parsed, offenders := walkNonTestGo(t, moduleRoot(t))
|
|
|
|
require.GreaterOrEqual(
|
|
t, parsed, minNonTestFiles,
|
|
"parsed %d non-test .go files, so this check found "+
|
|
"nothing to look at", parsed,
|
|
)
|
|
require.Empty(
|
|
t, offenders,
|
|
"Scan called on a receiver this check cannot show is a "+
|
|
"database/sql row handle. (*gorm.DB).Scan logs the "+
|
|
"statement with its bound values interpolated — use "+
|
|
"Find, Pluck, or Raw(...).Row().Scan instead. A "+
|
|
"database/sql Scan reached through a variable is "+
|
|
"reported too; write it as <producer>().Scan rather "+
|
|
"than widening this check.",
|
|
)
|
|
}
|
|
|
|
// scanGuardCase is one planted snippet and whether the check above
|
|
// should report it.
|
|
type scanGuardCase struct {
|
|
name string
|
|
body string
|
|
want int
|
|
}
|
|
|
|
func scanGuardCases() []scanGuardCase {
|
|
return []scanGuardCase{
|
|
{"gorm chain", `db.DB().Raw("SELECT 1").Scan(&v)`, 1},
|
|
{"gorm receiver", `gdb.Scan(&v)`, 1},
|
|
{"gorm via variable", "q := gdb.Raw(\"x\")\nq.Scan(&v)", 1},
|
|
{"gorm model chain", `gdb.Model(&x).Scan(&v)`, 1},
|
|
{"sql row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0},
|
|
{"sql rows", `gdb.Raw("SELECT 1").Rows().Scan(&v)`, 0},
|
|
{"unrelated call", `gdb.Find(&v)`, 0},
|
|
}
|
|
}
|
|
|
|
// TestScanGuard_ReportsPlantedCalls proves the check fires. Without it
|
|
// a detector that matched nothing would satisfy the walk above no
|
|
// matter what the tree contained.
|
|
func TestScanGuard_ReportsPlantedCalls(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range scanGuardCases() {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fset := token.NewFileSet()
|
|
src := fmt.Sprintf(
|
|
"package p\n\nfunc f() {\n\t%s\n}\n", tc.body,
|
|
)
|
|
|
|
file, err := parser.ParseFile(
|
|
fset, tc.name+".go", src, 0,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, unguardedScans(fset, file), tc.want)
|
|
})
|
|
}
|
|
}
|