| index 71275867a..cbd54f492 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -67,11 +67,21 @@ var movesCopyIDs = []string{ |
| "MV-N151", "MV-N152", |
| } |
| |
| -// copyRow is one row of the record. |
| +// copyRow is one row of the record. The File cell is the file the sentence is |
| +// emitted from, and may carry a note after the path in parentheses ("path (a |
| +// T3 note, not a failure)"); the Text cell is the sentence and nothing else. |
| type copyRow struct { |
| ID, File, Text, Status string |
| } |
| |
| +// Path is the File cell without its note. |
| +func (r copyRow) Path() string { |
| + if i := strings.Index(r.File, " ("); i > 0 && strings.HasSuffix(r.File, ")") { |
| + return r.File[:i] |
| + } |
| + return r.File |
| +} |
| + |
| var movesCopyIDPattern = regexp.MustCompile(`^(M-C|MV-N)[0-9]+$`) |
| |
| // parseMovesCopyRecord reads the record's tables. A data row is a line that |
| @@ -190,13 +200,13 @@ func checkMovesApplied(rows []copyRow, root string) []string { |
| if r.Status != "approved" { |
| continue |
| } |
| - body, err := os.ReadFile(filepath.Join(root, r.File)) |
| + body, err := os.ReadFile(filepath.Join(root, r.Path())) |
| if err != nil { |
| out = append(out, fmt.Sprintf("%s: %v", r.ID, err)) |
| continue |
| } |
| - if !movesTextApplied(r.File, r.Text, body) { |
| - out = append(out, fmt.Sprintf("%s: %s does not contain the approved text %q", r.ID, r.File, r.Text)) |
| + if !movesTextApplied(r.Path(), r.Text, body) { |
| + out = append(out, fmt.Sprintf("%s: %s does not contain the approved text %q", r.ID, r.Path(), r.Text)) |
| } |
| } |
| return out |
| @@ -388,6 +398,19 @@ func TestParseMovesCopyTable(t *testing.T) { |
| } |
| } |
| |
| +// A note after the path stays out of the sentence and out of the file name. |
| +func TestCopyRowPath(t *testing.T) { |
| + rows, err := parseMovesCopyTable("| id | file | text | status |\n|---|---|---|---|\n"+ |
| + "| MV-N1 | a/b.go (a T3 note, not a failure) | <NAME> (<kind>) | pending |\n"+ |
| + "| MV-N2 | a/b.go | a sentence | pending |\n", "x.md") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rows[0].Path() != "a/b.go" || rows[1].Path() != "a/b.go" || rows[0].Text != "<NAME> (<kind>)" { |
| + t.Errorf("rows = %+v", rows) |
| + } |
| +} |
| + |
| func TestParseMovesCopyRecordMissingFile(t *testing.T) { |
| if _, err := parseMovesCopyRecord("testdata/moves-copy/no-such-record.md"); err == nil { |
| t.Error("a record missing from the tree must fail to parse") |
| @@ -657,7 +680,11 @@ func formatPattern(f string) string { |
| type movesPkgIndex struct { |
| vars map[string]ast.Expr |
| funcs map[string]*ast.FuncDecl |
| + methods map[string][]*ast.FuncDecl |
| + decls []*ast.FuncDecl |
| noCalls bool // read a call to a package function as a wildcard |
| + // locals are the assignments to the variables of the function being read. |
| + locals map[string][]localAssign |
| } |
| |
| var movesPkgCache = map[string]*movesPkgIndex{} |
| @@ -666,12 +693,38 @@ var movesPkgCache = map[string]*movesPkgIndex{} |
| // helper in a sibling file can be named file:line. |
| var movesFset = token.NewFileSet() |
| |
| +func newPkgIndex() *movesPkgIndex { |
| + return &movesPkgIndex{vars: map[string]ast.Expr{}, funcs: map[string]*ast.FuncDecl{}, methods: map[string][]*ast.FuncDecl{}} |
| +} |
| + |
| +func (p *movesPkgIndex) addFile(f *ast.File) { |
| + for _, d := range f.Decls { |
| + switch d := d.(type) { |
| + case *ast.FuncDecl: |
| + p.decls = append(p.decls, d) |
| + if d.Recv == nil { |
| + p.funcs[d.Name.Name] = d |
| + } else { |
| + p.methods[d.Name.Name] = append(p.methods[d.Name.Name], d) |
| + } |
| + case *ast.GenDecl: |
| + for _, s := range d.Specs { |
| + if vs, ok := s.(*ast.ValueSpec); ok && len(vs.Names) == len(vs.Values) { |
| + for i, nm := range vs.Names { |
| + p.vars[nm.Name] = vs.Values[i] |
| + } |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| func loadMovesPkg(t *testing.T, dir string) *movesPkgIndex { |
| t.Helper() |
| if p, ok := movesPkgCache[dir]; ok { |
| return p |
| } |
| - p := &movesPkgIndex{vars: map[string]ast.Expr{}, funcs: map[string]*ast.FuncDecl{}} |
| + p := newPkgIndex() |
| movesPkgCache[dir] = p |
| ents, err := os.ReadDir(dir) |
| if err != nil { |
| @@ -686,31 +739,97 @@ func loadMovesPkg(t *testing.T, dir string) *movesPkgIndex { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - for _, d := range f.Decls { |
| - switch d := d.(type) { |
| - case *ast.FuncDecl: |
| - if d.Recv == nil { |
| - p.funcs[d.Name.Name] = d |
| + p.addFile(f) |
| + } |
| + return p |
| +} |
| + |
| +// localAssign is one assignment to a local variable: either an expression |
| +// (rhs), or position pos of a multi-value call, or a zero value (a declaration |
| +// with no value), or something the gate cannot read (a range variable). |
| +type localAssign struct { |
| + rhs ast.Expr |
| + call *ast.CallExpr |
| + pos int |
| + zero bool |
| + unreadable bool |
| +} |
| + |
| +// localAssigns lists every assignment to every variable of fd, by name. |
| +func localAssigns(fd *ast.FuncDecl) map[string][]localAssign { |
| + out := map[string][]localAssign{} |
| + if fd == nil || fd.Body == nil { |
| + return out |
| + } |
| + ast.Inspect(fd.Body, func(n ast.Node) bool { |
| + switch n := n.(type) { |
| + case *ast.AssignStmt: |
| + for i, l := range n.Lhs { |
| + id, ok := l.(*ast.Ident) |
| + if !ok || id.Name == "_" { |
| + continue |
| } |
| - case *ast.GenDecl: |
| - for _, s := range d.Specs { |
| - if vs, ok := s.(*ast.ValueSpec); ok && len(vs.Names) == len(vs.Values) { |
| - for i, nm := range vs.Names { |
| - p.vars[nm.Name] = vs.Values[i] |
| - } |
| + switch { |
| + case len(n.Rhs) == len(n.Lhs): |
| + out[id.Name] = append(out[id.Name], localAssign{rhs: n.Rhs[i]}) |
| + case len(n.Rhs) == 1: |
| + if c, ok := n.Rhs[0].(*ast.CallExpr); ok { |
| + out[id.Name] = append(out[id.Name], localAssign{call: c, pos: i}) |
| + } else { |
| + out[id.Name] = append(out[id.Name], localAssign{unreadable: true}) |
| } |
| } |
| } |
| + case *ast.ValueSpec: |
| + for i, id := range n.Names { |
| + switch { |
| + case len(n.Values) == len(n.Names): |
| + out[id.Name] = append(out[id.Name], localAssign{rhs: n.Values[i]}) |
| + case len(n.Values) == 0: |
| + out[id.Name] = append(out[id.Name], localAssign{zero: true}) |
| + case len(n.Values) == 1: |
| + if c, ok := n.Values[0].(*ast.CallExpr); ok { |
| + out[id.Name] = append(out[id.Name], localAssign{call: c, pos: i}) |
| + } |
| + } |
| + } |
| + case *ast.RangeStmt: |
| + for _, x := range []ast.Expr{n.Key, n.Value} { |
| + if id, ok := x.(*ast.Ident); ok && id.Name != "_" { |
| + out[id.Name] = append(out[id.Name], localAssign{unreadable: true}) |
| + } |
| + } |
| } |
| + return true |
| + }) |
| + return out |
| +} |
| + |
| +// calleeDecls finds the package function or methods a call may reach. A |
| +// selector is matched by method name only; the gate has no types. |
| +func calleeDecls(e *ast.CallExpr, pkg *movesPkgIndex) []*ast.FuncDecl { |
| + switch fn := e.Fun.(type) { |
| + case *ast.Ident: |
| + if d, ok := pkg.funcs[fn.Name]; ok { |
| + return []*ast.FuncDecl{d} |
| + } |
| + case *ast.SelectorExpr: |
| + if fn.Sel.Name == "Error" { |
| + return nil |
| + } |
| + return pkg.methods[fn.Sel.Name] |
| } |
| - return p |
| + return nil |
| } |
| |
| // patternsOf returns the sentence patterns an expression can produce: |
| -// literals, concatenations, fmt.Sprintf formats, package constants and |
| -// package functions that return strings. Anything it cannot read is a |
| -// wildcard. |
| +// literals, concatenations, fmt.Sprintf formats, package constants, local |
| +// variables (through every assignment), and package functions and methods |
| +// that return strings. Anything it cannot read is a wildcard. |
| func patternsOf(e ast.Expr, pkg *movesPkgIndex, depth int) []string { |
| + if depth > 4 { |
| + return []string{wild} |
| + } |
| switch e := e.(type) { |
| case *ast.BasicLit: |
| if e.Kind == token.STRING { |
| @@ -721,7 +840,23 @@ func patternsOf(e ast.Expr, pkg *movesPkgIndex, depth int) []string { |
| case *ast.ParenExpr: |
| return patternsOf(e.X, pkg, depth) |
| case *ast.Ident: |
| - if x, ok := pkg.vars[e.Name]; ok && depth < 4 { |
| + if as, ok := pkg.locals[e.Name]; ok { |
| + var out []string |
| + for _, a := range as { |
| + switch { |
| + case a.zero: |
| + out = append(out, "") |
| + case a.rhs != nil: |
| + out = append(out, patternsOf(a.rhs, pkg, depth+1)...) |
| + case a.call != nil: |
| + out = append(out, callPatterns(a.call, a.pos, pkg, depth+1)...) |
| + default: |
| + out = append(out, wild) |
| + } |
| + } |
| + return out |
| + } |
| + if x, ok := pkg.vars[e.Name]; ok { |
| return patternsOf(x, pkg, depth+1) |
| } |
| case *ast.BinaryExpr: |
| @@ -736,36 +871,70 @@ func patternsOf(e ast.Expr, pkg *movesPkgIndex, depth int) []string { |
| } |
| return out |
| case *ast.CallExpr: |
| - switch fn := e.Fun.(type) { |
| - case *ast.SelectorExpr: |
| - if id, ok := fn.X.(*ast.Ident); ok && len(e.Args) > 0 && |
| - ((id.Name == "fmt" && (fn.Sel.Name == "Sprintf" || fn.Sel.Name == "Errorf")) || |
| - (id.Name == "errors" && fn.Sel.Name == "New")) { |
| - var out []string |
| - for _, f := range patternsOf(e.Args[0], pkg, depth) { |
| - out = append(out, formatPattern(f)) |
| - } |
| - return out |
| + if isFormatCall(e) { |
| + var out []string |
| + for _, f := range patternsOf(e.Args[0], pkg, depth) { |
| + out = append(out, formatPattern(f)) |
| } |
| - case *ast.Ident: |
| - if d, ok := pkg.funcs[fn.Name]; ok && !pkg.noCalls && depth < 4 && returnsOneString(d) { |
| - var out []string |
| - ast.Inspect(d.Body, func(n ast.Node) bool { |
| - switch n := n.(type) { |
| - case *ast.FuncLit: |
| - return false |
| - case *ast.ReturnStmt: |
| - if len(n.Results) == 1 { |
| - out = append(out, patternsOf(n.Results[0], pkg, depth+1)...) |
| - } |
| + return out |
| + } |
| + return callPatterns(e, 0, pkg, depth) |
| + } |
| + return []string{wild} |
| +} |
| + |
| +// callPatterns reads the sentences result pos of a call to a package function |
| +// or method can be: the patterns of its returns, read in the callee's own |
| +// locals. |
| +func callPatterns(e *ast.CallExpr, pos int, pkg *movesPkgIndex, depth int) []string { |
| + if pkg.noCalls || depth > 4 { |
| + return []string{wild} |
| + } |
| + decls := calleeDecls(e, pkg) |
| + if len(decls) == 0 { |
| + return []string{wild} |
| + } |
| + var out []string |
| + for _, d := range decls { |
| + if d.Body == nil || !stringResults(d)[pos] { |
| + out = append(out, wild) |
| + continue |
| + } |
| + hp := *pkg |
| + hp.locals = localAssigns(d) |
| + nres := resultCount(d) |
| + ast.Inspect(d.Body, func(n ast.Node) bool { |
| + switch n := n.(type) { |
| + case *ast.FuncLit: |
| + return false |
| + case *ast.ReturnStmt: |
| + switch { |
| + case len(n.Results) == nres && pos < len(n.Results): |
| + out = append(out, patternsOf(n.Results[pos], &hp, depth+1)...) |
| + case len(n.Results) == 1 && nres > 1: |
| + if c, ok := n.Results[0].(*ast.CallExpr); ok { |
| + out = append(out, callPatterns(c, pos, &hp, depth+1)...) |
| } |
| - return true |
| - }) |
| - return out |
| + } |
| } |
| + return true |
| + }) |
| + } |
| + return out |
| +} |
| + |
| +func resultCount(d *ast.FuncDecl) int { |
| + if d.Type.Results == nil { |
| + return 0 |
| + } |
| + n := 0 |
| + for _, f := range d.Type.Results.List { |
| + if len(f.Names) == 0 { |
| + n++ |
| } |
| + n += len(f.Names) |
| } |
| - return []string{wild} |
| + return n |
| } |
| |
| func returnsOneString(d *ast.FuncDecl) bool { |
| @@ -856,8 +1025,9 @@ func scanMovesFileFull(t *testing.T, root, file string, noCalls bool) ([]movesEm |
| seenVio := map[string]bool{} |
| var fn string |
| var ctx *sinkCtx |
| + cur := pkg |
| emit := func(pos token.Pos, kind string, e ast.Expr, fragment bool) { |
| - for _, p := range patternsOf(e, pkg, 0) { |
| + for _, p := range patternsOf(e, cur, 0) { |
| if informative(p) { |
| out = append(out, movesEmission{file, fset.Position(pos).Line, fset.Position(e.End()).Line, fn, kind, p, fragment}) |
| } |
| @@ -896,7 +1066,10 @@ func scanMovesFileFull(t *testing.T, root, file string, noCalls bool) ([]movesEm |
| continue |
| } |
| fn = fd.Name.Name |
| - ctx = newSinkCtx(file, fd, pkg) |
| + fp := *pkg |
| + fp.locals = localAssigns(fd) |
| + cur = &fp |
| + ctx = newSinkCtx(file, fd, cur) |
| inTable := tables[fn] |
| strs := stringResults(fd) |
| returnsSentence := movesReturnedSentences[file][fn] && len(strs) > 0 |
| @@ -974,6 +1147,7 @@ func scanMovesFileFull(t *testing.T, root, file string, noCalls bool) ([]movesEm |
| } |
| } |
| case *ast.ReturnStmt: |
| + ctx.curReturn = n |
| switch { |
| case inTable: |
| for _, r := range n.Results { |
| @@ -1015,13 +1189,25 @@ var movesFuncSinks = map[string]map[string]int{ |
| // code instead. TestMovesSinkAllowListIsHonest holds the list to this. |
| var movesSinkAllow = map[string]map[string]string{} |
| |
| +// movesMappingHelpers are functions that map a refusal from another package |
| +// onto the sentence an operator sees, echoing the refusal's own text only in a |
| +// case whose guard tests that text against recorded literals (equality, |
| +// HasPrefix, HasSuffix or Contains with a literal). A `return x` in such a case |
| +// is taken; any other return is checked as usual. Anything else answers a fixed |
| +// sentence. |
| +var movesMappingHelpers = map[string]map[string]bool{ |
| + "internal/console/apiinstancemove.go": {"phase1Conflict": true}, |
| +} |
| + |
| // sinkCtx classifies what a sink argument is, inside one function. |
| type sinkCtx struct { |
| - file string |
| - fd *ast.FuncDecl |
| - pkg *movesPkgIndex |
| - fwd map[string]bool // string parameters whose values are sinks at the call sites |
| - fwdFn map[string]bool // function parameters that are sentence helpers at the call sites |
| + file string |
| + fd *ast.FuncDecl |
| + pkg *movesPkgIndex |
| + fwd map[string]bool // string parameters whose values are sinks at the call sites |
| + fwdFn map[string]bool // function parameters that are sentence helpers at the call sites |
| + curReturn *ast.ReturnStmt // the return being checked, for a mapping helper |
| + visiting map[string]bool // helpers and parameters being read, so a cycle ends |
| } |
| |
| func paramNames(ft *ast.FuncType) []string { |
| @@ -1041,7 +1227,7 @@ func paramNames(ft *ast.FuncType) []string { |
| } |
| |
| func newSinkCtx(file string, fd *ast.FuncDecl, pkg *movesPkgIndex) *sinkCtx { |
| - c := &sinkCtx{file: file, fd: fd, pkg: pkg, fwd: map[string]bool{}, fwdFn: map[string]bool{}} |
| + c := &sinkCtx{file: file, fd: fd, pkg: pkg, fwd: map[string]bool{}, fwdFn: map[string]bool{}, visiting: map[string]bool{}} |
| if fd == nil { |
| return c |
| } |
| @@ -1076,32 +1262,21 @@ func newSinkCtx(file string, fd *ast.FuncDecl, pkg *movesPkgIndex) *sinkCtx { |
| return c |
| } |
| |
| -// isConstLike reports whether an identifier names a package-level string. |
| -func (c *sinkCtx) pkgString(name string) (ast.Expr, bool) { |
| - x, ok := c.pkg.vars[name] |
| - return x, ok |
| +// sub is a context for another function of the package, sharing the cycle guard. |
| +func (c *sinkCtx) sub(d *ast.FuncDecl) *sinkCtx { |
| + hp := *c.pkg |
| + hp.locals = localAssigns(d) |
| + n := newSinkCtx(c.file, d, &hp) |
| + n.visiting = c.visiting |
| + return n |
| } |
| |
| -// literalish reports whether e is, on its face, a piece of fixed text: a |
| -// string literal, a package const, a Sprintf or errors.New with a literal |
| -// format, or a call to a recognised sentence helper. |
| -func (c *sinkCtx) literalish(e ast.Expr, depth int) bool { |
| - switch e := e.(type) { |
| - case *ast.ParenExpr: |
| - return c.literalish(e.X, depth) |
| - case *ast.BasicLit: |
| - return e.Kind == token.STRING |
| - case *ast.Ident: |
| - _, ok := c.pkgString(e.Name) |
| - return ok |
| - case *ast.CallExpr: |
| - return isFormatCall(e) && c.formatIsLiteral(e) |
| - case *ast.BinaryExpr: |
| - if e.Op == token.ADD { |
| - return c.literalish(e.X, depth) || c.literalish(e.Y, depth) |
| - } |
| +func (c *sinkCtx) pkgString(name string) (ast.Expr, bool) { |
| + if _, shadowed := c.pkg.locals[name]; shadowed { |
| + return nil, false |
| } |
| - return false |
| + x, ok := c.pkg.vars[name] |
| + return x, ok |
| } |
| |
| func isFormatCall(e *ast.CallExpr) bool { |
| @@ -1125,61 +1300,26 @@ func (c *sinkCtx) formatIsLiteral(e *ast.CallExpr) bool { |
| return false |
| } |
| |
| -// localSentence reports whether a function assigns a string literal (or a |
| -// literal concatenation or Sprintf) to the local variable name. |
| -func (c *sinkCtx) localSentence(name string) bool { |
| - if c.fd == nil { |
| - return false |
| - } |
| - found := false |
| - ast.Inspect(c.fd.Body, func(n ast.Node) bool { |
| - switch n := n.(type) { |
| - case *ast.AssignStmt: |
| - for i, l := range n.Lhs { |
| - if id, ok := l.(*ast.Ident); ok && id.Name == name && i < len(n.Rhs) && isSentenceLiteral(n.Rhs[i]) { |
| - found = true |
| - } |
| - } |
| - case *ast.ValueSpec: |
| - for i, id := range n.Names { |
| - if id.Name == name && i < len(n.Values) && isSentenceLiteral(n.Values[i]) { |
| - found = true |
| - } |
| - } |
| - } |
| - return !found |
| - }) |
| - return found |
| -} |
| - |
| -// isSentenceLiteral is true for text written in place: a string literal, a |
| -// Sprintf or errors.New, or a concatenation with a string literal in it. A |
| -// reference to a const is not text written in place. |
| -func isSentenceLiteral(e ast.Expr) bool { |
| +// literalish reports whether e is, on its face, a piece of fixed text: a |
| +// string literal, a package const, a Sprintf or errors.New with a literal |
| +// format, or a concatenation with such a part. |
| +func (c *sinkCtx) literalish(e ast.Expr) bool { |
| switch e := e.(type) { |
| case *ast.ParenExpr: |
| - return isSentenceLiteral(e.X) |
| + return c.literalish(e.X) |
| case *ast.BasicLit: |
| return e.Kind == token.STRING |
| + case *ast.Ident: |
| + _, ok := c.pkgString(e.Name) |
| + return ok |
| case *ast.CallExpr: |
| - return isFormatCall(e) |
| + return isFormatCall(e) && c.formatIsLiteral(e) |
| case *ast.BinaryExpr: |
| - return e.Op == token.ADD && (isSentenceLiteral(e.X) || isSentenceLiteral(e.Y)) |
| + return e.Op == token.ADD && (c.literalish(e.X) || c.literalish(e.Y)) |
| } |
| return false |
| } |
| |
| -// operandProblem is the one thing a placeholder operand may not be: a local |
| -// variable that was given a sentence. |
| -func (c *sinkCtx) operandProblem(e ast.Expr) string { |
| - if id, ok := e.(*ast.Ident); ok { |
| - if _, isPkg := c.pkgString(id.Name); !isPkg && c.localSentence(id.Name) { |
| - return fmt.Sprintf("%s holds a sentence assigned before the sink, so the sentence is never read where it is spoken", id.Name) |
| - } |
| - } |
| - return "" |
| -} |
| - |
| func addOperands(e ast.Expr, out *[]ast.Expr) { |
| if b, ok := e.(*ast.BinaryExpr); ok && b.Op == token.ADD { |
| addOperands(b.X, out) |
| @@ -1218,12 +1358,17 @@ func render(e ast.Expr) string { |
| // |
| // Taken: a string literal; a package const; a concatenation with a literal |
| // part or a Sprintf with a literal format (the other operands are the |
| -// placeholders the record names); a call to a package function that returns |
| -// a string and whose own returns are taken; a parameter that is itself a |
| -// sink at its call sites; a Check's own Reason (every Check is made at a |
| -// sink). Everything else is refused: a variable, err.Error(), a method's |
| -// result, a field, an expression the gate cannot read. |
| +// placeholders the record names); a call to a package function or method |
| +// whose result is a string and whose every return is itself taken; a local |
| +// variable whose every assignment is taken; a parameter that is a sink at its |
| +// call sites, or whose every call site passes a taken argument; a Check's own |
| +// Reason (every Check is made at a sink); a mapping helper's guarded echo. |
| +// Everything else is refused: err.Error(), a method's result the gate cannot |
| +// read, a field, an index, an expression it cannot read. |
| func (c *sinkCtx) check(e ast.Expr, depth int) string { |
| + if depth > 6 { |
| + return "" |
| + } |
| switch e := e.(type) { |
| case *ast.ParenExpr: |
| return c.check(e.X, depth) |
| @@ -1233,16 +1378,7 @@ func (c *sinkCtx) check(e ast.Expr, depth int) string { |
| } |
| return "not a string literal" |
| case *ast.Ident: |
| - if c.fwd[e.Name] { |
| - return "" |
| - } |
| - if x, ok := c.pkgString(e.Name); ok { |
| - if depth >= 4 { |
| - return "" |
| - } |
| - return (&sinkCtx{pkg: c.pkg, fwd: map[string]bool{}, fwdFn: map[string]bool{}}).check(x, depth+1) |
| - } |
| - return fmt.Sprintf("%s is a variable, not a literal, a const or a sentence helper", e.Name) |
| + return c.identProblem(e, depth) |
| case *ast.SelectorExpr: |
| if e.Sel.Name == "Reason" { |
| return "" |
| @@ -1256,13 +1392,11 @@ func (c *sinkCtx) check(e ast.Expr, depth int) string { |
| addOperands(e, &ops) |
| lit := false |
| for _, o := range ops { |
| - if c.literalish(o, depth) { |
| + if c.literalish(o) { |
| lit = true |
| - if why := c.check(o, depth); why != "" { |
| + if why := c.check(o, depth+1); why != "" { |
| return why |
| } |
| - } else if why := c.operandProblem(o); why != "" { |
| - return why |
| } |
| } |
| if !lit { |
| @@ -1274,48 +1408,166 @@ func (c *sinkCtx) check(e ast.Expr, depth int) string { |
| if !c.formatIsLiteral(e) { |
| return fmt.Sprintf("%s has a format that is not a literal", render(e)) |
| } |
| - for _, a := range e.Args[1:] { |
| - if why := c.operandProblem(a); why != "" { |
| - return why |
| + return "" |
| + } |
| + if id, ok := e.Fun.(*ast.Ident); ok && c.fwdFn[id.Name] { |
| + return "" |
| + } |
| + return c.callProblem(e, 0, depth) |
| + } |
| + return fmt.Sprintf("%s is not a sentence the gate can read", render(e)) |
| +} |
| + |
| +func (c *sinkCtx) identProblem(id *ast.Ident, depth int) string { |
| + if c.fwd[id.Name] { |
| + return "" |
| + } |
| + if c.guardedEcho(id) { |
| + return "" |
| + } |
| + if as, ok := c.pkg.locals[id.Name]; ok { |
| + for _, a := range as { |
| + switch { |
| + case a.zero: |
| + case a.rhs != nil: |
| + if why := c.check(a.rhs, depth+1); why != "" { |
| + return fmt.Sprintf("%s is assigned something that is not a sentence: %s", id.Name, why) |
| + } |
| + case a.call != nil: |
| + if why := c.callProblem(a.call, a.pos, depth+1); why != "" { |
| + return fmt.Sprintf("%s is assigned something that is not a sentence: %s", id.Name, why) |
| } |
| + default: |
| + return fmt.Sprintf("%s is assigned a value the gate cannot read", id.Name) |
| } |
| - return "" |
| } |
| - if id, ok := e.Fun.(*ast.Ident); ok { |
| - if c.fwdFn[id.Name] { |
| - return "" |
| + return "" |
| + } |
| + if x, ok := c.pkgString(id.Name); ok { |
| + return (&sinkCtx{pkg: c.pkg, fwd: map[string]bool{}, fwdFn: map[string]bool{}, visiting: c.visiting}).check(x, depth+1) |
| + } |
| + if c.fd != nil { |
| + for i, n := range paramNames(c.fd.Type) { |
| + if n == id.Name { |
| + return c.paramProblem(i, id.Name, depth) |
| } |
| - return c.helperProblem(id.Name, depth) |
| } |
| - return fmt.Sprintf("%s forwards a call's result", render(e)) |
| } |
| - return fmt.Sprintf("%s is not a sentence the gate can read", render(e)) |
| + return fmt.Sprintf("%s is a variable, not a literal, a const or a sentence helper", id.Name) |
| +} |
| + |
| +// paramProblem checks a parameter used as a sentence: every call site in the |
| +// package must pass an argument that is a sentence. |
| +func (c *sinkCtx) paramProblem(idx int, name string, depth int) string { |
| + key := fmt.Sprintf("param %p %d", c.fd, idx) |
| + if c.visiting[key] { |
| + return "" |
| + } |
| + c.visiting[key] = true |
| + defer delete(c.visiting, key) |
| + sites := 0 |
| + for _, d := range c.pkg.decls { |
| + if d.Body == nil { |
| + continue |
| + } |
| + var caller *sinkCtx |
| + var why string |
| + ast.Inspect(d.Body, func(n ast.Node) bool { |
| + call, ok := n.(*ast.CallExpr) |
| + if !ok || why != "" { |
| + return why == "" |
| + } |
| + named := "" |
| + switch f := call.Fun.(type) { |
| + case *ast.Ident: |
| + named = f.Name |
| + case *ast.SelectorExpr: |
| + named = f.Sel.Name |
| + } |
| + if named != c.fd.Name.Name || idx >= len(call.Args) { |
| + return true |
| + } |
| + sites++ |
| + if caller == nil { |
| + caller = c.sub(d) |
| + } |
| + if w := caller.check(call.Args[idx], depth+1); w != "" { |
| + why = fmt.Sprintf("parameter %s is passed a value that is not a sentence at %s: %s", name, movesFset.Position(call.Pos()), w) |
| + } |
| + return true |
| + }) |
| + if why != "" { |
| + return why |
| + } |
| + } |
| + if sites == 0 { |
| + return fmt.Sprintf("parameter %s has no call site in the package, so its value is unknown", name) |
| + } |
| + return "" |
| } |
| |
| -// helperProblem checks a package function used as a sentence helper: it must |
| -// return one string, and every return must itself be taken. |
| -func (c *sinkCtx) helperProblem(name string, depth int) string { |
| - d, ok := c.pkg.funcs[name] |
| - if !ok || d.Body == nil { |
| - return fmt.Sprintf("%s(...) is not a sentence helper of this package", name) |
| +// callProblem checks result pos of a call: the callee must be a package |
| +// function or method with a string there, and every return of it must be a |
| +// sentence. |
| +func (c *sinkCtx) callProblem(e *ast.CallExpr, pos, depth int) string { |
| + if sel, ok := e.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Error" { |
| + return fmt.Sprintf("%s forwards an error's text", render(e)) |
| + } |
| + decls := calleeDecls(e, c.pkg) |
| + if len(decls) == 0 { |
| + return fmt.Sprintf("%s forwards a call's result the gate cannot read", render(e)) |
| } |
| - if !returnsOneString(d) { |
| - return fmt.Sprintf("%s(...) does not return a sentence", name) |
| + for _, d := range decls { |
| + if why := c.helperProblem(d, pos, depth); why != "" { |
| + return why |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +// helperProblem checks every return of d at result position pos. |
| +func (c *sinkCtx) helperProblem(d *ast.FuncDecl, pos, depth int) string { |
| + if d.Body == nil || !stringResults(d)[pos] { |
| + return fmt.Sprintf("%s(...) does not return a sentence at result %d", d.Name.Name, pos) |
| } |
| - if depth >= 4 { |
| + key := fmt.Sprintf("helper %p %d", d, pos) |
| + if c.visiting[key] { |
| return "" |
| } |
| - hc := &sinkCtx{fd: d, pkg: c.pkg, fwd: map[string]bool{}, fwdFn: map[string]bool{}} |
| + c.visiting[key] = true |
| + defer delete(c.visiting, key) |
| + hc := c.sub(d) |
| + nres := resultCount(d) |
| + var named []string |
| + for _, f := range d.Type.Results.List { |
| + for _, n := range f.Names { |
| + named = append(named, n.Name) |
| + } |
| + } |
| var why string |
| ast.Inspect(d.Body, func(n ast.Node) bool { |
| switch n := n.(type) { |
| case *ast.FuncLit: |
| return false |
| case *ast.ReturnStmt: |
| - if why == "" && len(n.Results) == 1 { |
| - if w := hc.check(n.Results[0], depth+1); w != "" { |
| - why = fmt.Sprintf("helper %s, at %s: %s", name, movesFset.Position(n.Pos()), w) |
| + if why != "" { |
| + return false |
| + } |
| + hc.curReturn = n |
| + var w string |
| + switch { |
| + case len(n.Results) == nres: |
| + w = hc.check(n.Results[pos], depth+1) |
| + case len(n.Results) == 1 && nres > 1: |
| + if call, ok := n.Results[0].(*ast.CallExpr); ok { |
| + w = hc.callProblem(call, pos, depth+1) |
| } |
| + case len(n.Results) == 0 && pos < len(named): |
| + w = hc.check(&ast.Ident{Name: named[pos]}, depth+1) |
| + } |
| + hc.curReturn = nil |
| + if w != "" { |
| + why = fmt.Sprintf("helper %s, at %s: %s", d.Name.Name, movesFset.Position(n.Pos()), w) |
| } |
| } |
| return why == "" |
| @@ -1323,12 +1575,95 @@ func (c *sinkCtx) helperProblem(name string, depth int) string { |
| return why |
| } |
| |
| +// guardedEcho reports whether id is the whole of the return being checked, |
| +// inside a case of a mapping helper whose guard tests id against literals. |
| +func (c *sinkCtx) guardedEcho(id *ast.Ident) bool { |
| + if c.curReturn == nil || c.fd == nil || !movesMappingHelpers[c.file][c.fd.Name.Name] { |
| + return false |
| + } |
| + direct := false |
| + for _, r := range c.curReturn.Results { |
| + if r == ast.Expr(id) { |
| + direct = true |
| + } |
| + } |
| + if !direct { |
| + return false |
| + } |
| + var cc *ast.CaseClause |
| + var stack []ast.Node |
| + ast.Inspect(c.fd.Body, func(n ast.Node) bool { |
| + if n == nil { |
| + stack = stack[:len(stack)-1] |
| + return true |
| + } |
| + stack = append(stack, n) |
| + if n == ast.Node(c.curReturn) { |
| + for i := len(stack) - 1; i >= 0; i-- { |
| + if k, ok := stack[i].(*ast.CaseClause); ok { |
| + cc = k |
| + break |
| + } |
| + } |
| + } |
| + return cc == nil |
| + }) |
| + if cc == nil || len(cc.List) == 0 { |
| + return false |
| + } |
| + for _, g := range cc.List { |
| + if !guardsLiteral(g, id.Name, c) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// guardsLiteral reports whether g tests name against literals only: name == |
| +// literal, or HasPrefix, HasSuffix or Contains of name and a literal, joined |
| +// by &&. |
| +func guardsLiteral(g ast.Expr, name string, c *sinkCtx) bool { |
| + switch g := g.(type) { |
| + case *ast.ParenExpr: |
| + return guardsLiteral(g.X, name, c) |
| + case *ast.BinaryExpr: |
| + switch g.Op { |
| + case token.LAND: |
| + return guardsLiteral(g.X, name, c) && guardsLiteral(g.Y, name, c) |
| + case token.EQL: |
| + l, lok := g.X.(*ast.Ident) |
| + r, rok := g.Y.(*ast.Ident) |
| + switch { |
| + case lok && l.Name == name: |
| + return c.literalish(g.Y) && !(rok && r.Name == name) |
| + case rok && r.Name == name: |
| + return c.literalish(g.X) |
| + } |
| + } |
| + case *ast.CallExpr: |
| + sel, ok := g.Fun.(*ast.SelectorExpr) |
| + if !ok || len(g.Args) != 2 { |
| + return false |
| + } |
| + x, isID := sel.X.(*ast.Ident) |
| + a0, a0ok := g.Args[0].(*ast.Ident) |
| + if !isID || x.Name != "strings" || !a0ok || a0.Name != name { |
| + return false |
| + } |
| + switch sel.Sel.Name { |
| + case "HasPrefix", "HasSuffix", "Contains": |
| + return c.literalish(g.Args[1]) |
| + } |
| + } |
| + return false |
| +} |
| + |
| // checkFuncArg checks the function a call hands to a sentence-function |
| // parameter: a package function that is a sentence helper. |
| func (c *sinkCtx) checkFuncArg(e ast.Expr) string { |
| if id, ok := e.(*ast.Ident); ok { |
| - if _, isFn := c.pkg.funcs[id.Name]; isFn { |
| - return c.helperProblem(id.Name, 0) |
| + if d, isFn := c.pkg.funcs[id.Name]; isFn { |
| + return c.helperProblem(d, 0, 0) |
| } |
| } |
| return fmt.Sprintf("%s is passed where a sentence helper is expected", render(e)) |
| @@ -1749,16 +2084,23 @@ func wantViolations(t *testing.T, name string, got []movesViolation, wants ...st |
| |
| func TestMovesSinkFixtures(t *testing.T) { |
| wantViolations(t, "a forwarded error", sinkFixture(t, "forwarded-error"), |
| - "emit.go:7 (jsonError in refuse): err.Error(...) forwards a call's result", |
| + "emit.go:7 (jsonError in refuse): err.Error(...) forwards an error's text", |
| "emit.go:18 (Check.Reason in report): helper why, at ", |
| ) |
| - wantViolations(t, "a message assigned to a local before the sink", sinkFixture(t, "local-variable"), |
| - "emit.go:8 (jsonError in refuse): msg is a variable", |
| - "emit.go:16 (jsonError in refuseLate): text is a variable", |
| - "emit.go:21 (jsonError in inside): gone holds a sentence assigned before the sink", |
| + wantViolations(t, "a local with one assignment that is an error's text", sinkFixture(t, "local-variable"), |
| + "emit.go:10 (jsonError in mixed): msg is assigned something that is not a sentence: err.Error(...) forwards an error's text", |
| + "emit.go:15 (jsonError in fromMultiValue): text is assigned something that is not a sentence: helper reasonOf, at ", |
| + "emit.go:24 (jsonError in rangeVar): n is assigned a value the gate cannot read", |
| + ) |
| + wantViolations(t, "a helper with one bad return", sinkFixture(t, "helper-bad-return"), |
| + "emit.go:7 (jsonError in refuse): helper say, at testdata/moves-copy/sinks/helper-bad-return/emit.go:14:2: err.Error(...) forwards an error's text", |
| + ) |
| + wantViolations(t, "a parameter one call site gives an error", sinkFixture(t, "param"), |
| + "emit.go:6 (jsonError in refuse): helper wrap, at ", |
| + "emit.go:7 (jsonError in refuse): helper wrap, at ", |
| ) |
| wantViolations(t, "values the gate cannot read", sinkFixture(t, "unresolved"), |
| - "emit.go:6 (jsonError in methodResult): s.render(...) forwards a call's result", |
| + "emit.go:6 (jsonError in methodResult): s.render(...) forwards a call's result the gate cannot read", |
| "emit.go:10 (jsonError in indexed): names is not a sentence the gate can read", |
| "emit.go:14 (Check.Reason in bare): a + b is a concatenation with no literal part", |
| "emit.go:18 (jsonError in dynamicFormat): fmt.Sprintf(...) has a format that is not a literal", |
| @@ -1767,6 +2109,25 @@ func TestMovesSinkFixtures(t *testing.T) { |
| wantViolations(t, "sentences the gate can read", sinkFixture(t, "ok")) |
| } |
| |
| +// A mapping helper may echo a refusal's text only in a case whose guard tests |
| +// it against literals. |
| +func TestMovesMappingHelperFixture(t *testing.T) { |
| + file := filepath.Join("testdata", "moves-copy", "sinks", "mapping", "emit.go") |
| + movesMappingHelpers[file] = map[string]bool{"mapRefusal": true, "unguarded": true, "badGuard": true} |
| + defer delete(movesMappingHelpers, file) |
| + _, vios := scanMovesFileFull(t, ".", file, false) |
| + wantViolations(t, "a mapping helper", vios, |
| + "emit.go:8 (jsonError in refuse): helper unguarded, at ", |
| + "emit.go:9 (jsonError in refuse): helper badGuard, at ", |
| + ) |
| + // Without the configuration the same echo is refused. |
| + delete(movesMappingHelpers, file) |
| + _, vios = scanMovesFileFull(t, ".", file, false) |
| + if len(vios) != 3 { |
| + t.Errorf("an unconfigured mapping helper: %d violations, want 3: %v", len(vios), vios) |
| + } |
| +} |
| + |
| // An allow-list entry exempts a sink, but it needs a reason and must still |
| // exempt something. |
| func checkSinkAllow(allow map[string]map[string]string, vios []movesViolation) []string { |