diff --git a/tools/annotate_parallel/main.go b/tools/annotate_parallel/main.go new file mode 100644 index 0000000..468e34c --- /dev/null +++ b/tools/annotate_parallel/main.go @@ -0,0 +1,172 @@ +// 命令 annotate_parallel 按 SDK 声明风格为工具加并发安全声明。 +// +// 风格要求(照 SDK 的 NoMemory 走,不自创): +// +// · 声明项是**结构体字段**(ParallelSafe / Serial),不是注释标记; +// · 插在 Parameters 之后、handler 之前 —— 即字面量的**末尾**, +// 与 SDK 里 NoMemory/ContextPolicy/RecallPolicy 的位置一致; +// · Name 保持在首位,不打散 gofmt 对齐。 +// +// 为什么用括号深度定位插入点:之前用正则找"最后一个顶层字段", +// 会被嵌套 map 里的同形文本骗到,结果把声明插到 Parameters 中间, +// 甚至把文件改坏(823 处重排)。深度计数是唯一可靠的。 +// +// 用法:annotate_parallel ... +// +// kind: parallel | serial +package main + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +func main() { + if len(os.Args) < 3 { + fmt.Fprintln(os.Stderr, "用法: annotate_parallel ...") + 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) + } +} diff --git a/tools/audit_parallel.go b/tools/audit_parallel.go new file mode 100644 index 0000000..ed9e50e --- /dev/null +++ b/tools/audit_parallel.go @@ -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 ... +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 ...") + 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: "", 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 一律不声明并发安全 —— 追不到实现就不能声称安全。") +}