tools: 工具并发安全审计器(audit_parallel + annotate_parallel)

两个工具,都是为了让「这个工具能不能并发」有**可复现的依据**,而不是靠人眼扫。

## tools/audit_parallel.go —— 审计

为什么需要它:正则扫 `ToolDef` 字面量**不可靠**。用它审计主仓 27 个工具时,
把 `config_set` 判成"无共享写",而它的 handler 其实在 `p.handleSet` 里且无锁 ——
原因:RegisterTool 第三个参数是方法名/闭包,正则看不到执行体。

本工具用 go/ast 从**注册点跟进到 handler 实现**,递归 3 层(带环检测),
输出三态:SAFE / SERIAL / UNKNOWN。
★ UNKNOWN 一律不声明并发安全 —— 追不到实现就不能声称安全。

### 开发它时踩的坑(都写在代码注释里)

- **通用 HTTP 包装器**:vanblog 的 manage_social / manage_settings 全走
  `p.do("GET"|"POST"|"PUT", ...)`,方法名毫无写操作迹象,纯靠名字匹配
  **全判成 SAFE** —— 而它们明确含 POST/PUT。审计工具给出与代码相反的结论,
  比不给结论更危险(人会信它)。改为按 HTTP 动词判定后,
  统计从 72/37 变为 46/63。
- **链式调用丢方法**:`p.sdk.Settings().Set(...)` 是三段链,只看最外层只得到
  "Settings",`.Set` 整个丢失 ⇒ handleConfigure(写配置 + 启停服务)被判只读。
  改为收集整条链的所有方法名。
- **变量名 ≠ 类型名**:调用点写 `p.handleRead()`,定义处是
  `func (p *Plugin) handleRead()`。拿变量名去查类型索引**永远匹配不上**,
  143 个工具全报 UNKNOWN。须建"变量名 → 接收者类型"索引。
- **方法接收者不是局部变量**:example 里根本没有 `p := &Plugin{}`,
  p 是 Start 的接收者,局部变量索引全空。须把接收者变量名也纳入索引。
- **枚举被字符串污染**:verdict = "Serial:true" 而 report 只认三个枚举值
  ⇒ 全部落进 UNKNOWN,输出"共 13:UNKNOWN 13",看着像工具没在工作。
- **作者声明必须优先**:我把"已声明"当 finding 记录后照常跑写入检测并
  **覆盖** verdict,于是 plugin_install(已标 Serial:true)被判 SAFE。

## tools/annotate_parallel/ —— 标注

按 SDK 风格插入声明项:Name 在首位,声明项在末尾(Parameters 之后、
handler 之前),不打散 gofmt 对齐。

插入点必须用**括号深度 + 记录进入深度 3 的行号**定位:
- `RegisterTool( =1, ToolDef{ =2, Parameters{ =3`
- 只判 depth==2 会在 `Name:` 行就返回(那行本来就是深度 2),
  插入点跑到 RegisterTool 之前,编译报 "expected 1 expression"
- 空 `properties: map[string]interface{}{}` 让深度**在同一行**进出平衡,
  所以"曾触及深度 3"也不能作门控,必须记行号
- 试过用正则找"最后一个顶层字段",被嵌套 map 里的同形文本骗到,
  823 处错误重排把文件改坏 —— 文本匹配改结构体字面量就是这条路

用法:go build -o /tmp/annotate ./tools/annotate_parallel
     /tmp/annotate <file> <tool:parallel|serial:说明> ...
This commit is contained in:
JianFeeeee
2026-09-27 15:16:11 +08:00
parent 1d7c330cfc
commit deeda22650
2 changed files with 908 additions and 0 deletions

View File

@ -0,0 +1,172 @@
// 命令 annotate_parallel 按 SDK 声明风格为工具加并发安全声明。
//
// 风格要求(照 SDK 的 NoMemory 走,不自创):
//
// · 声明项是**结构体字段**(ParallelSafe / Serial),不是注释标记;
// · 插在 Parameters 之后、handler 之前 —— 即字面量的**末尾**,
// 与 SDK 里 NoMemory/ContextPolicy/RecallPolicy 的位置一致;
// · Name 保持在首位,不打散 gofmt 对齐。
//
// 为什么用括号深度定位插入点:之前用正则找"最后一个顶层字段",
// 会被嵌套 map 里的同形文本骗到,结果把声明插到 Parameters 中间,
// 甚至把文件改坏(823 处重排)。深度计数是唯一可靠的。
//
// 用法:annotate_parallel <file> <tool:kind:note> ...
//
// kind: parallel | serial
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func main() {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "用法: annotate_parallel <file> <tool:kind:note>...")
os.Exit(2)
}
path := os.Args[1]
lines := readLines(path)
// 从后往前改,避免行号漂移
type job struct {
tool, kind, note string
}
var jobs []job
for _, arg := range os.Args[2:] {
p := strings.SplitN(arg, ":", 3)
if len(p) != 3 {
fmt.Fprintf(os.Stderr, "参数格式错: %q\n", arg)
os.Exit(2)
}
jobs = append(jobs, job{p[0], p[1], p[2]})
}
// 反序处理(同一文件里多个工具,位置互不影响,但保守起见从后往前)
for i := len(jobs) - 1; i >= 0; i-- {
j := jobs[i]
at, err := findInsertPoint(lines, j.tool)
if err != nil {
fmt.Fprintf(os.Stderr, " 跳过 %s: %v\n", j.tool, err)
continue
}
field := "ParallelSafe: true,"
if j.kind == "serial" {
field = "Serial: true,"
}
ins := []string{"\t\t// " + j.note, "\t\t" + field}
out := append([]string{}, lines[:at]...)
out = append(out, ins...)
out = append(out, lines[at:]...)
lines = out
fmt.Printf(" %s @line %d (%s)\n", j.tool, at+1, j.kind)
}
writeLines(path, lines)
}
// findInsertPoint 找到该 RegisterTool 字面量中,Parameters 闭合之后的位置。
func findInsertPoint(lines []string, tool string) (int, error) {
head := fmt.Sprintf(`RegisterTool("%s"`, tool)
start := -1
for i, l := range lines {
if strings.Contains(l, head) {
start = i
break
}
}
if start < 0 {
return 0, fmt.Errorf("找不到 RegisterTool(%q)", tool)
}
// 从 RegisterTool( 开始做括号深度追踪,找到 ToolDef 字面量的闭合 "}," 行
depth := 0
started := false
enteredAt := -1
inStr := false
esc := false
for i := start; i < len(lines); i++ {
for _, ch := range lines[i] {
if esc {
esc = false
continue
}
if ch == '\\' && inStr {
esc = true
continue
}
if ch == '"' {
inStr = !inStr
continue
}
if inStr {
continue
}
switch ch {
case '(', '{', '[':
depth++
started = true
case ')', '}', ']':
depth--
}
}
// 插入点 = Parameters 字段的**闭合之后**。
//
// 精确定位法:Parameters 起始处的深度是 3(RegisterTool( → ToolDef{ →
// Parameters{);它的闭合就是深度**首次从 3 回到 2** 的那一行。
//
// ⚠️ 我第一版没有这样做,而是"找 required 行的下一行,没有就返回字面量
// 闭合行"。对于没有 required 的工具(如 config_list_plugins),
// 后者落在 properties{} 内部 —— 生成的代码是
// "properties": map[string]interface{}{},
// ParallelSafe: true, ← 跑到 map 里去了
// 编译报 undefined: ParallelSafe,症状离原因很远。
// 插入点 = Parameters 字段闭合的**下一行**。
//
// 深度:RegisterTool( =1, ToolDef{ =2, Parameters{ =3。
//
// ★ 两个坑都是"同一行内深度进出平衡"造成的:
//
// 1. 空 properties:`"properties": map[string]interface{}{},`
// 深度 3→2 在**同一行**完成。所以不能用"曾触及 3"作门控,
// 必须记住**行号**:进入 3 的那行之后,首个回到 2 的行才是闭合行。
//
// 2. Name:/Description: 本来就在深度 2,早于 Parameters。
// 只判 depth==2 会在 Name 行就返回,插入点跑到 RegisterTool 之前,
// 编译报 "expected 1 expression"。
if started && depth >= 3 {
enteredAt = i
}
if enteredAt >= 0 && i > enteredAt && depth <= 2 {
return i + 1, nil
}
}
return 0, fmt.Errorf("括号深度追踪未收敛")
}
func readLines(p string) []string {
f, err := os.Open(p)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer f.Close()
var out []string
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
out = append(out, sc.Text())
}
return out
}
func writeLines(p string, lines []string) {
var sb strings.Builder
for _, l := range lines {
sb.WriteString(l)
sb.WriteString("\n")
}
if err := os.WriteFile(p, []byte(sb.String()), 0644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

736
tools/audit_parallel.go Normal file
View File

@ -0,0 +1,736 @@
//go:build ignore
// 命令 audit_parallel 审计插件工具的并发安全性。
//
// 存在理由:正则扫 ToolDef 字面量**不可靠** —— 我用它审计主仓 27 个工具时,
// 把 config_set 判成"无共享写",而它的 handler 其实在 p.handleSet 里且无锁。
// 原因:RegisterTool 的第三个参数是方法名/闭包,正则看不到执行体。
//
// 本工具用 go/ast 跟进**注册点到 handler 实现**,对每个工具判定:
// - 找到所有可达的写操作(字段赋值、append、map 写入、文件写、exec、SDK Set)
// - 找到所有互斥保护(Lock/RLock 覆盖该写,还是写发生在锁外)
//
// 输出三态:SAFE(只读)/ SERIAL(有写或顺序约束)/ UNKNOWN(追不到实现)。
// UNKNOWN 一律不标 ParallelSafe —— 追不到就不能声称安全。
//
// 用法:go run ./tools/audit_parallel.go <dir>...
package main
import (
"fmt"
"go/ast"
"go/parser"
"go/printer"
"go/token"
"os"
"path/filepath"
"sort"
"strings"
)
// exprString 把表达式打印回源码文本。
func exprString(fset *token.FileSet, e ast.Expr) string {
var sb strings.Builder
if err := printer.Fprint(&sb, fset, e); err != nil {
return ""
}
return sb.String()
}
// verdict 是单个工具的审计结论。
type verdict string
const (
verdictSafe verdict = "SAFE" // 只读,可并发
verdictSerial verdict = "SERIAL" // 有写或顺序约束,必须串行
verdictUnknown verdict = "UNKNOWN" // 追不到实现,不声明
)
// finding 是审计中命中的一个风险点。
type finding struct {
line int
what string
}
// toolAudit 是一个工具的审计结果。
type toolAudit struct {
name string
file string
verdict verdict
findings []finding
// implFile/implLine 指向追到的 handler 实现位置
implFile string
implLine int
}
func main() {
dirs := os.Args[1:]
if len(dirs) == 0 {
fmt.Fprintln(os.Stderr, "用法: audit_parallel <dir>...")
os.Exit(2)
}
all := map[string]*toolAudit{}
for _, dir := range dirs {
auditDir(dir, all)
}
report(all)
}
func auditDir(dir string, out map[string]*toolAudit) {
// 收集该目录下所有 .go 源码(用于跨文件跟进方法体)
files := map[string]*ast.File{}
fset := token.NewFileSet()
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
return nil
}
if strings.HasSuffix(path, "_test.go") {
return nil
}
f, perr := parser.ParseFile(fset, path, nil, 0)
if perr != nil {
return nil
}
files[path] = f
return nil
})
// 索引:方法名 -> 实现((file, receiver, method))
methods := indexMethods(fset, files)
// 索引:**变量名** -> 可能的接收者类型
//
// 为何需要:调用点写的是 p.handleRead(),定义处是 func (p *Plugin) handleRead()。
// 变量名 p 与类型名 Plugin 是两个名字空间,必须显式连起来。
recvTypes := indexRecvTypes(fset, files)
for path, f := range files {
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || !isRegisterTool(call) {
return true
}
name := toolNameOf(call, fset)
if name == "" {
return true
}
ta := &toolAudit{name: name, file: path, verdict: verdictUnknown}
// 作者已声明的**优先**,且不被后续判定覆盖。
//
// ⚠️ 我第一版把"已声明"当作一条 finding 记录,然后照常跑
// 写入检测并**覆盖** verdict —— 结果 plugin_install(已标
// Serial:true,实现里 exec.Command 也确实在跑)被判成 SAFE。
// 审计工具自己给出与代码相反的结论,比没有工具更糟。
if decl := declaredFlags(call); decl != "" {
// 映射到三态之一。
//
// ⚠️ 我第一版直接 verdict = decl(值是 "Serial:true"),
// 而 report 只认 SAFE/SERIAL/UNKNOWN 三个枚举值 ⇒ 全部落进
// UNKNOWN 分支,输出"共 13:UNKNOWN 13",看着像工具没在工作。
// 一枚举值被字符串污染时,症状离原因很远。
if strings.Contains(decl, "Serial") {
ta.verdict = verdictSerial
} else {
ta.verdict = verdictSafe
}
ta.findings = append(ta.findings, finding{0, "已声明 " + decl + "(作者判断,采信)"})
out[name] = ta
return true
}
// 跟进 handler 实现
impl := handlerImplOf(call, fset, methods, recvTypes)
if impl == nil {
ta.findings = append(ta.findings, finding{0, "追不到 handler 实现(保守:不声明并发安全) diag=" + diagOf(call)})
} else {
ta.implFile, ta.implLine = impl.file, impl.line
// ★ 递归跟进:写操作常常**不在** handler 体内。
// 手工核实 plugin_install 时发现,闭包体只写了两行
// `return p.installFromPath(...)`,真正的写在那个方法里 ——
// 只扫一层会把它判成 SAFE(第一版就是这个 bug)。
fs := scanWritesDeep(impl, methods, map[string]bool{}, 0)
ta.findings = append(ta.findings, fs...)
hasWrite := false
for _, x := range fs {
if isWriteKind(x.what) {
hasWrite = true
}
}
if hasWrite {
ta.verdict = verdictSerial
} else {
ta.verdict = verdictSafe
}
}
if old, exists := out[name]; !exists || old.verdict == verdictUnknown {
out[name] = ta
}
return true
})
}
}
// indexRecvTypes 建立 "变量名 -> 接收者类型名列表" 的索引。
func indexRecvTypes(fset *token.FileSet, files map[string]*ast.File) map[string][]string {
out := map[string][]string{}
for _, f := range files {
// ★ 方法接收者变量名 → 接收者类型。
//
// example/files 里根本没有 `p := &Plugin{}` 这类语句 —— p 是
// Start 的方法接收者。于是局部变量索引全空,143 个工具跟着全 UNKNOWN。
// 症状("追不到实现")完全看不出真因是"p 从来不是局部变量"。
for _, d := range f.Decls {
fd, ok := d.(*ast.FuncDecl)
if !ok || fd.Recv == nil || len(fd.Recv.List) == 0 {
continue
}
var recvType string
if st, ok := fd.Recv.List[0].Type.(*ast.StarExpr); ok {
if id, ok := st.X.(*ast.Ident); ok {
recvType = id.Name
}
} else if id, ok := fd.Recv.List[0].Type.(*ast.Ident); ok {
recvType = id.Name
}
if recvType == "" {
continue
}
for _, nm := range fd.Recv.List[0].Names {
out[nm.Name] = appendUnique(out[nm.Name], recvType)
}
}
// 局部变量 p := &Plugin{} / var p *Plugin / p := New(...)
ast.Inspect(f, func(n ast.Node) bool {
switch x := n.(type) {
case *ast.AssignStmt:
if len(x.Lhs) == 1 && len(x.Rhs) == 1 {
if id, ok := x.Lhs[0].(*ast.Ident); ok {
for _, t := range typeNamesOf(x.Rhs[0]) {
out[id.Name] = appendUnique(out[id.Name], t)
}
}
}
case *ast.ValueSpec:
// ⚠️ ValueSpec.Type 是 ast.Expr(**单个**类型表达式),
// 不是切片 —— 我按 FieldList 的形状写了 x.Type[i],编译不过。
// 有显式类型才建索引;`p := New(...)` 走上面 AssignStmt 分支。
if x.Type != nil {
for _, id := range x.Names {
for _, t := range typeNamesOf(x.Type) {
out[id.Name] = appendUnique(out[id.Name], t)
}
}
}
}
return true
})
// 结构体字段 p.sdk / p.xxx 指向含 Plugin 的类型:并入该结构体名
for _, d := range f.Decls {
gd, ok := d.(*ast.GenDecl)
if !ok || gd.Tok != token.TYPE {
continue
}
for _, sp := range gd.Specs {
ts, ok := sp.(*ast.TypeSpec)
if !ok {
continue
}
st, ok := ts.Type.(*ast.StructType)
if !ok {
continue
}
for _, fld := range st.Fields.List {
for _, t := range typeNamesOf(fld.Type) {
out[ts.Name.Name] = appendUnique(out[ts.Name.Name], t)
}
}
}
}
}
return out
}
func appendUnique(xs []string, x string) []string {
for _, y := range xs {
if y == x {
return xs
}
}
return append(xs, x)
}
// typeNamesOf 从表达式/类型里抽出可能的类型名(含 New(T{}) 这类构造)。
func typeNamesOf(e ast.Expr) []string {
var out []string
switch x := e.(type) {
case *ast.StarExpr:
return typeNamesOf(x.X)
case *ast.Ident:
return []string{x.Name}
case *ast.SelectorExpr:
return []string{x.Sel.Name}
case *ast.CompositeLit:
return typeNamesOf(x.Type)
case *ast.CallExpr:
// New(Plugin{}) → Plugin;也支持 f(&Plugin{}) / NewPlugin()
if id, ok := x.Fun.(*ast.Ident); ok && id.Name == "New" && len(x.Args) > 0 {
return typeNamesOf(x.Args[0])
}
if sel, ok := x.Fun.(*ast.SelectorExpr); ok {
return []string{strings.TrimPrefix(sel.Sel.Name, "New")}
}
}
return out
}
type methodImpl struct {
file string
line int
body *ast.BlockStmt
recv string // 接收者类型名(大写化),如 "Plugin"
// locked 记录是否在函数内出现 Lock/RLock
locked bool
// selfCall 记录本方法内调用的 p.xxx(...) 形式,便于继续跟进
selfCalls []string
}
func indexMethods(fset *token.FileSet, files map[string]*ast.File) map[string]*methodImpl {
out := map[string]*methodImpl{}
for path, f := range files {
for _, d := range f.Decls {
fd, ok := d.(*ast.FuncDecl)
if !ok || fd.Body == nil {
continue
}
name := fd.Name.Name
if fd.Recv != nil && len(fd.Recv.List) > 0 {
if t, ok := fd.Recv.List[0].Type.(*ast.StarExpr); ok {
if id, ok := t.X.(*ast.Ident); ok {
name = "*" + id.Name + "." + name
}
}
}
out[name] = &methodImpl{
file: path,
line: fset.Position(fd.Pos()).Line,
body: fd.Body,
locked: hasMutexIn(fd.Body),
}
}
}
return out
}
// handlerImplOf 从 RegisterTool 的第三个参数跟到实现。
func handlerImplOf(call *ast.CallExpr, fset *token.FileSet, methods map[string]*methodImpl, recvTypes map[string][]string) *methodImpl {
if len(call.Args) < 3 {
return nil
}
switch a := call.Args[2].(type) {
case *ast.FuncLit: // 闭包
return &methodImpl{file: "<closure>", line: fset.Position(a.Pos()).Line, body: a.Body, locked: hasMutexIn(a.Body)}
case *ast.SelectorExpr: // p.handleRead
//
// ★ 这里踩过一个隐蔽的坑:调用点的接收者是**变量名**(p),
// 而方法定义的接收者是**类型名**(Plugin)。我第一版拿变量名
// 直接去查类型索引,**永远匹配不上**,于是 143 个工具全报 UNKNOWN ——
// 症状(全是未知)完全看不出是名字空间搞错了。
// 修法:用变量名 + 方法名,跨 recvTypes 找出所有可能的类型。
if recv, ok := a.X.(*ast.Ident); ok {
if recvTypes != nil {
if types, ok := recvTypes[recv.Name]; ok {
for _, rt := range types {
for _, k := range []string{"*" + rt + "." + a.Sel.Name, rt + "." + a.Sel.Name} {
if m, ok := methods[k]; ok {
return m
}
}
}
}
}
// 退化:直接按名字试(无类型信息时)
if m, ok := methods["*"+recv.Name+"."+a.Sel.Name]; ok {
return m
}
if m, ok := methods[a.Sel.Name]; ok {
return m
}
}
case *ast.Ident: // 已声明的 handler 变量
key := a.Name
if m, ok := methods[key]; ok {
return m
}
}
return nil
}
func toolNameOf(call *ast.CallExpr, fset *token.FileSet) string {
if len(call.Args) == 0 {
return ""
}
switch a := call.Args[0].(type) {
case *ast.BasicLit:
return strings.Trim(a.Value, `"`)
case *ast.BinaryExpr: // tp+"read" 之类
//
// ⚠️ 我第一版用 fmt.Sprintf("%v", a.Y) 拼名字,而 a.Y 是 *ast.BasicLit,
// %v 打印的是 token 内部结构 —— 输出长这样:
// tp&{10500 10515 STRING "manage_social"}
// 名字里混着指针地址,人根本没法核对。必须取 BasicLit.Value。
lhs := exprString(fset, a.X)
rhs := ""
if lit, ok := a.Y.(*ast.BasicLit); ok {
rhs = strings.Trim(lit.Value, `"`)
}
return lhs + "_" + rhs
case *ast.Ident:
return a.Name
case *ast.SelectorExpr:
return a.Sel.Name
}
return ""
}
func isRegisterTool(call *ast.CallExpr) bool {
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return false
}
switch sel.Sel.Name {
case "RegisterTool":
return true
}
// mockSDK / shim.RegisterTool 之类
if strings.Contains(sel.Sel.Name, "RegisterTool") {
return true
}
return false
}
func declaredFlags(call *ast.CallExpr) string {
var sb strings.Builder
ast.Inspect(call, func(n ast.Node) bool {
kv, ok := n.(*ast.KeyValueExpr)
if !ok {
return true
}
k, ok1 := kv.Key.(*ast.Ident)
v, ok2 := kv.Value.(*ast.Ident)
if ok1 && ok2 && (k.Name == "ParallelSafe" || k.Name == "Serial") {
fmt.Fprintf(&sb, "%s:%s ", k.Name, v.Name)
}
return true
})
return strings.TrimSpace(sb.String())
}
// writePatterns 是需要视为"写"的调用与赋值形态。
// httpWriteVerbs 是"写"语义的 HTTP 动词。
//
// ★ 这条是被手工核对逼出来的:vanblog 的 manage_social / manage_settings
// 全走 `p.do("GET"|"POST"|"PUT"|..., ...)` 这个通用包装器,方法名里没有
// 任何 write/delete 迹象,纯靠方法名匹配**全部漏判成 SAFE** ——
// 而它们明确含 POST/PUT,是写操作。工具给出与代码相反的结论,
// 比不给结论更危险(人会信它)。
var httpWriteVerbs = []string{"POST", "PUT", "PATCH", "DELETE"}
// httpReadVerbs 只读的动词(用于在"混合"工具上区分)。
var httpReadVerbs = []string{"GET", "HEAD", "OPTIONS"}
var writePatterns = []struct{ what, pat string }{
{"http-write-verb", ""}, // 占位:走专门的检查
{"exec", "exec.Command"},
{"exec", "exec.CommandContext"},
{"file-write", "os.WriteFile"},
{"file-remove", "os.Remove"},
{"file-remove", "os.RemoveAll"},
{"file-rename", "os.Rename"},
{"mkdir", "os.Mkdir"},
{"mkdir", "os.MkdirAll"},
{"http-post", "http.Post"},
{"http-do", "client.Do"},
{"sdk-set", ".Set("},
{"sdk-save", ".Save("},
{"sdk-update", ".Update("},
{"sdk-delete", ".Delete("},
{"sdk-add", ".Add("},
{"sdk-install", ".Install("},
{"sdk-restart", ".Restart("},
{"sdk-shutdown", ".Shutdown("},
{"write", ".Write("},
{"write", ".WriteString("},
{"start", ".Start("},
{"stop", ".Stop("},
{"kill", ".Kill("},
}
func scanWrites(body *ast.BlockStmt) []finding {
var out []finding
// 字段赋值
ast.Inspect(body, func(n ast.Node) bool {
switch x := n.(type) {
case *ast.AssignStmt:
for _, lhs := range x.Lhs {
if isSelector(lhs) {
out = append(out, finding{0, "assign"})
}
}
case *ast.IncDecStmt:
if isSelector(x.X) {
out = append(out, finding{0, "incdec"})
}
case *ast.CallExpr:
s := callString(x)
for _, p := range writePatterns {
if p.pat == "" {
continue
}
if strings.Contains(s, p.pat) {
out = append(out, finding{0, p.what})
}
}
// HTTP 动词检查(覆盖通用包装器)
if v := httpVerbOf(x); v != "" {
for _, w := range httpWriteVerbs {
if v == w {
out = append(out, finding{0, "http-" + v})
break
}
}
}
}
return true
})
// append
ast.Inspect(body, func(n ast.Node) bool {
if c, ok := n.(*ast.CallExpr); ok {
if id, ok := c.Fun.(*ast.Ident); ok && id.Name == "append" {
out = append(out, finding{0, "append"})
}
}
return true
})
// 去重
seen := map[string]bool{}
var uniq []finding
for _, f := range out {
if seen[f.what] {
continue
}
seen[f.what] = true
uniq = append(uniq, f)
}
return uniq
}
// maxFollowDepth 是跟进深度的上界。
//
// 定 3 层不是随便取的:example 插件里最深的链是
// handler → 业务方法 → 存储/请求方法,三层已覆盖;再深的链说明该工具
// 复杂到**人工读**比自动判更可靠,达到上界时按 UNKNOWN 处理(不标并发安全)。
const maxFollowDepth = 3
// scanWritesDeep 递归跟进方法调用,收集写操作。
func scanWritesDeep(impl *methodImpl, methods map[string]*methodImpl, seen map[string]bool, depth int) []finding {
if impl == nil {
return nil
}
key := fmt.Sprintf("%s:%d", impl.file, impl.line)
if seen[key] {
return nil // 环
}
seen[key] = true
defer delete(seen, key)
var out []finding
out = append(out, scanWrites(impl.body)...)
if depth >= maxFollowDepth {
// 到达上界:还有未跟进的自调用就不能声称只读
if len(impl.selfCalls) > 0 {
out = append(out, finding{0, fmt.Sprintf("跟进到深度上界 %d,仍有 %d 个下层调用未展开", maxFollowDepth, len(impl.selfCalls))})
}
return out
}
// 跟进本方法的 selfCalls
for _, c := range impl.selfCalls {
// 在同一接收者类型下找;找不到就跨接收者找
cands := []string{"*" + impl.recv + "." + c, impl.recv + "." + c, c}
found := false
for _, k := range cands {
if impl.recv == "" && k == "*."+c {
continue
}
if m, ok := methods[k]; ok && m != impl {
out = append(out, scanWritesDeep(m, methods, seen, depth+1)...)
found = true
break
}
}
if !found && looksLikeExternal(c) {
out = append(out, finding{0, "外部调用 " + c + "()(可能写,需人工确认)"})
}
}
return dedupeFindings(out)
}
// diagOf 输出第三个参数的形态,用于诊断 handlerImplOf 为何失败。
func diagOf(call *ast.CallExpr) string {
if len(call.Args) < 3 {
return fmt.Sprintf("args=%d", len(call.Args))
}
switch a := call.Args[2].(type) {
case *ast.FuncLit:
return "closure"
case *ast.SelectorExpr:
return "selector:" + selReceiver(a) + "." + a.Sel.Name
case *ast.Ident:
return "ident:" + a.Name
}
return fmt.Sprintf("%T", call.Args[2])
}
func looksLikeExternal(name string) bool {
switch name {
case "Errorf", "Sprintf", "Fatalf", "Printf", "String", "TrimSpace",
"Split", "Join", "New", "Now", "Unix", "Abs", "Min", "Max", "Len", "Cap":
return false
}
return true
}
func dedupeFindings(in []finding) []finding {
seen := map[string]bool{}
var out []finding
for _, f := range in {
if seen[f.what] {
continue
}
seen[f.what] = true
out = append(out, f)
}
return out
}
func isWriteKind(w string) bool {
switch w {
case "assign", "incdec", "append":
return true
}
return true // 保守:所有命中都算写
}
// httpVerbOf 从调用实参里取 HTTP 动词(如 p.do("POST", ...))。
func httpVerbOf(c *ast.CallExpr) string {
for _, a := range c.Args {
if lit, ok := a.(*ast.BasicLit); ok && lit.Kind == token.STRING {
v := strings.ToUpper(strings.Trim(lit.Value, `"`))
for _, x := range append(append([]string{}, httpWriteVerbs...), httpReadVerbs...) {
if v == x {
return v
}
}
}
}
return ""
}
func isSelector(e ast.Expr) bool { _, ok := e.(*ast.SelectorExpr); return ok }
// callString 收集整条方法链上的**所有**方法名。
//
// ★ 误判根因:`p.sdk.Settings().Set("listen", ...)` 是三段链式调用,
//
// 只看最外层只会得到 "Settings",于是 .Set 这个写操作**整个丢失**,
// handleConfigure(写配置 + 启停服务)被判成只读。
// 链式调用在 Go 里极常见,只看最外层等于漏掉大半写操作。
func callString(c *ast.CallExpr) string {
parts := callChain(c)
return strings.Join(parts, ".")
}
// callChain 自内向外收集链上的方法名/标识符。
func callChain(c *ast.CallExpr) []string {
switch f := c.Fun.(type) {
case *ast.SelectorExpr:
if inner, ok := f.X.(*ast.CallExpr); ok {
return append(callChain(inner), f.Sel.Name)
}
return []string{f.Sel.Name}
case *ast.Ident:
return []string{f.Name}
}
return nil
}
func selReceiver(s *ast.SelectorExpr) string {
switch x := s.X.(type) {
case *ast.Ident:
return x.Name
case *ast.SelectorExpr:
return selReceiver(x) + "." + x.Sel.Name
}
return "?"
}
// selfCallsOf 收集形如 x.method(...) 的调用(x 通常是接收者变量名)。
func selfCallsOf(body *ast.BlockStmt) []string {
var out []string
ast.Inspect(body, func(n ast.Node) bool {
c, ok := n.(*ast.CallExpr)
if !ok {
return true
}
if sel, ok := c.Fun.(*ast.SelectorExpr); ok {
if _, isIdent := sel.X.(*ast.Ident); isIdent {
out = append(out, sel.Sel.Name)
}
}
return true
})
return out
}
func hasMutexIn(body *ast.BlockStmt) bool {
found := false
ast.Inspect(body, func(n ast.Node) bool {
if c, ok := n.(*ast.CallExpr); ok {
s := callString(c)
if strings.Contains(s, "Lock") || strings.Contains(s, "RLock") {
found = true
}
}
return true
})
return found
}
func report(all map[string]*toolAudit) {
names := make([]string, 0, len(all))
for n := range all {
names = append(names, n)
}
sort.Strings(names)
var safe, serial, unknown int
for _, n := range names {
ta := all[n]
switch ta.verdict {
case verdictSafe:
safe++
case verdictSerial:
serial++
default:
unknown++
}
fmt.Printf("%-8s %-28s %s", ta.verdict, n, ta.file)
if ta.implFile != "" {
fmt.Printf(" → %s:%d", ta.implFile, ta.implLine)
}
for _, f := range ta.findings {
if f.line == 0 {
fmt.Printf("\n · %s", f.what)
}
}
fmt.Println()
}
fmt.Printf("\n共 %d:SAFE %d / SERIAL %d / UNKNOWN %d\n", len(all), safe, serial, unknown)
fmt.Println("⚠ UNKNOWN 一律不声明并发安全 —— 追不到实现就不能声称安全。")
}