Files
homeagent-sdk/tools/audit_parallel.go
JianFeeeee deeda22650 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:说明> ...
2026-09-27 16:17:04 +08:00

737 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//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 一律不声明并发安全 —— 追不到实现就不能声称安全。")
}