Files
homeagent-sdk/tools/audit_parallel.go
JianFeeeee 1b8218afa9 fix(tools): 审计器补齐 recv/selfCalls 索引与只读白名单;标注器定位修正
## audit_parallel 的四个误判来源

**① indexMethods 没设 recv 和 selfCalls ⇒ 递归跟进空转**
`scanWritesDeep` 靠 recv 组候选键、靠 selfCalls 才知道跟进谁,缺任一个
就静默漏判。handleRestart 只调 p.stopServer/startServer 两个方法,
因此被判成"只读" —— 而 stopServer 里有 `p.server = nil`。
(我先补了闭包那条分支的 selfCalls,误以为问题在闭包上,其实是
 indexMethods 整体缺字段。)

**② writePatterns 写了 ".Set(" 而 callString 收集的是方法名**
`p.sdk.Settings().Set(...)` 是三段链,callString 返回 "Settings.Set",
永远匹配不上 ".Set("。漏判比误判更难发现:结果看着仍然合理。
→ 模式改为不带括号。

**③ looksLikeExternal 用黑名单 ⇒ 纯读标准库调用全被判"可能写"**
Marshal / ReadAll / NewRequest / NewReader 都不带写操作迹象,却被判写,
120 个工具里 119 个判成 SERIAL —— 等于工具没在工作,却**看上去在工作**
(保守方向不会引起怀疑)。
→ 改为 knownReadOnlyCalls 白名单。判定原则:**默认怀疑,明确信任** ——
   写不动的东西要逐个列出来。

**④ 工具名提取打印 AST 内部结构**
`fmt.Sprintf("%v", a.Y)` 输出 `tp&{10500 10515 STRING "manage_social"}`,
名字里混着指针地址,人没法核对。
→ 改用 BasicLit.Value + 归一化去掉运行期前缀。

## annotate_parallel 的插入点定位

改文本匹配改结构体字面量这条路走了三次弯:
- 正则找"最后一个顶层字段"被嵌套 map 里的同形文本骗到,823 处重排改坏文件
- 变量前缀匹配没要求 RegisterTool( 在**同一行**,命中函数体里散落的字面量,
  起点错到 switch case 中间,报错指向一处看起来完全无辜的分支
- 只按行末**净**深度判断:Parameters 写在单行时(进出同一行,净变化 0)
  永远察觉不到曾进入深度 3,追踪一路跑到 1305 行才"收敛"
→ 最终按**行内峰值深度** + 记录进入深度 3 的行号判定。

## 幂等

未标注过的工具重复跑会插入第二份 ParallelSafe(duplicate field 编译错误)。
→ 加 blockHasDecl 预检。
2026-09-27 16:17:04 +08:00

816 lines
24 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
recv := ""
if fd.Recv != nil && len(fd.Recv.List) > 0 {
rt := fd.Recv.List[0].Type
if t, ok := rt.(*ast.StarExpr); ok {
if id, ok := t.X.(*ast.Ident); ok {
recv = id.Name
}
} else if id, ok := rt.(*ast.Ident); ok {
recv = id.Name
}
if recv != "" {
name = "*" + recv + "." + name
}
}
// ★ recv 与 selfCalls 都必须填:scanWritesDeep 用 recv 组候选键去
// 跟进下层方法,用 selfCalls 才知道要跟进谁。缺任何一个,
// 递归跟进就是空转 —— handlerRestart → p.stopServer/startServer
// 这类"只调两个方法"的写法会静默漏判成只读。
mi := &methodImpl{
file: path,
line: fset.Position(fd.Pos()).Line,
body: fd.Body,
recv: recv,
locked: hasMutexIn(fd.Body),
selfCalls: selfCallsOf(fd.Body),
}
out[name] = mi
if recv != "" {
out[recv+"."+fd.Name.Name] = mi
}
}
}
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: // 闭包
// ⚠️ recv 留空是有代价的:scanWritesDeep 跟进 selfCalls 时用
// "*"+recv+"."+name 找方法实现,recv 为空就永远找不到 ⇒
// 闭包里对 p.xxx(...) 的调用全部追不下去,write 被静默漏掉。
// 症状是"看起来有扫描、实际漏判"。
return &methodImpl{file: "<closure>", line: fset.Position(a.Pos()).Line, body: a.Body, locked: hasMutexIn(a.Body), selfCalls: selfCallsOf(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, `"`)
}
// 归一化:变量前缀(p.name / tp / p.tp)在运行期才确定,
// 静态只保留字面量那一段,前缀分隔符一并去掉。
if _, isIdent := a.X.(*ast.Ident); isIdent {
return rhs
}
if i := strings.Index(lhs, "."); i >= 0 {
lhs = lhs[i+1:]
}
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"},
// ⚠️ 这些模式**不带括号**:callString 收集的是链上方法名并用点连起来
// ("Settings.Set"),不是完整调用文本。我第一版写成 ".Set(",
// 于是永远匹配不上 —— handleConfigure(写配置 + 启停服务)被判只读。
// 症状是"漏判",比误判更难发现:结果看起来仍然合理。
{"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"},
{"start", ".Start"},
{"stop", ".Stop"},
{"kill", ".Kill"},
{"os-write", "os.WriteFile"},
{"os-remove", "os.Remove"},
{"exec", "exec.Command"},
}
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])
}
// knownReadOnlyCalls 是**确认无副作用**的调用/构造器。
//
// ⚠️ 这张表必须显式列出,不能靠"名字不像写操作"来猜:我第一版用
// looksLikeExternal(黑名单),结果 Marshal / ReadAll / NewRequest /
// NewReader 这些**纯读**的标准库调用全被判"可能写" ⇒ 只读的
// get_article 变成 SERIAL,120 个工具里 119 个被判串行 ——
// 等于工具没在工作,却看上去在工作(保守方向不会引起怀疑)。
//
// 判定原则:**默认怀疑,明确信任**。写不动的东西要逐个列出来。
var knownReadOnlyCalls = map[string]bool{
// 格式化
"Sprintf": true, "Fprintf": true, "Errorf": true, "Fatalf": true,
"Printf": true, "Sprintln": true, "Sprint": true, "Sscanf": true,
// 字符串(纯函数)
"String": true, "TrimSpace": true, "Trim": true, "TrimPrefix": true,
"TrimSuffix": true, "Split": true, "SplitN": true, "Join": true,
"Replace": true, "ReplaceAll": true, "ToLower": true, "ToUpper": true,
"Contains": true, "HasPrefix": true, "HasSuffix": true, "Fields": true,
"Repeat": true, "EqualFold": true, "Title": true,
// 数值
"Min": true, "Max": true, "Abs": true, "Round": true, "Floor": true, "Ceil": true,
// 编解码(纯变换)
"Marshal": true, "Unmarshal": true, "NewDecoder": true, "NewEncoder": true,
// JSON 读取(只读文件/流,不写)
"NewReader": true, "ReadAll": true, "Read": true, "Decode": true,
// HTTP 只读侧
"NewRequest": true, "NewRequestWithContext": true, "Parse": true, "ParseForm": true,
// 时间
"Now": true, "Unix": true, "ParseDuration": true, "After": true,
// 容器
"New": true, "NewMap": true, "Keys": true, "Values": true,
"Len": true, "Cap": true, "Copy": true, "Append": true,
// 容器查
"Get": true, "Load": true, "Exists": true, "List": true, "Query": true,
// 文件只读
"Stat": true, "ReadDir": true, "ReadFile": true, "Glob": true, "Walk": true,
// 错误
"Is": true, "As": true, "Unwrap": true, "Error": true,
}
func looksLikeExternal(name string) bool {
if knownReadOnlyCalls[name] {
return false
}
// 指针/包前缀形式(strings.TrimSpace)取最后一段
if i := strings.LastIndex(name, "."); i >= 0 {
if knownReadOnlyCalls[name[i+1:]] {
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 一律不声明并发安全 —— 追不到实现就不能声称安全。")
}