package trace import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "unicode/utf8" ) // maxDigestHex 是 Digest.SHA256 保留的十六进制位数(16 位 = 8 字节 / 64 bit)。 // 它足够把同一次运行里的两段不同内容区分开(相关性排查够用),又短到不可能据以 // 反推正文;再短的碰撞面就不值得了。 const maxDigestHex = 16 // Digest 是一个值的「体积 + 指纹」:字节数、rune 数、内容 sha256 前缀。 // // 它是本包对「脱敏后还要留下可诊断线索」这一要求的答案 —— 正文被丢弃的地方 // (写侧 JSONL、span 上的结果/回复)留下一个 Digest,于是「这次调用的输出有多大、 // 两次是不是同一份内容」仍可判断,而正文本身不出现。 // // 🔴 Digest **不含正文**,因此可以原样穿过本包的两条投影(出图投影与写侧投影)。 // 注意指纹的边界:sha256 只能证明「两份不同的内容指纹不同」,对**低熵**取值 // (例如一个短口令)它不构成保密承诺 —— 但它是截断前缀且不与正文同行,攻击者 // 拿不到「指纹 ↔ 明文」的对照面。 type Digest struct { // Bytes 是取值编码后的字节数。 Bytes int `json:"bytes"` // Runes 是字符串取值的 rune 数(非字符串取值为 0,随即省略)。 Runes int `json:"runes,omitempty"` // SHA256 是内容 sha256 的十六进制前缀(maxDigestHex 位)。 SHA256 string `json:"sha256"` } // DigestOf 对字符串取摘要:字节数、rune 数与内容 sha256 前缀。空串给出 // {0 0 } —— 「真的没有内容」与「有内容但很小」因此可分辨。 func DigestOf(s string) Digest { sum := sha256.Sum256([]byte(s)) return Digest{ Bytes: len(s), Runes: utf8.RuneCountInString(s), SHA256: hex.EncodeToString(sum[:])[:maxDigestHex], } } // DigestOfValue 对任意值取摘要。字符串按原文取摘要(不走 JSON 编码,避免引号 // 把体积算大);其余值先 JSON 编码(与出图/摘要渲染同一口径),编码失败才回落 // %v —— 与 renderToolResultSummary 的取舍一致,使「体积」这个词在两侧同一含义。 func DigestOfValue(v any) Digest { switch x := v.(type) { case nil: return Digest{} case string: return DigestOf(x) case []byte: return DigestOf(string(x)) default: b, err := json.Marshal(x) if err != nil { return DigestOf(fmt.Sprintf("%v", x)) } return DigestOf(string(b)) } } // digestFrom 报告 v 是否已经是一个 Digest —— 即一个「构造上就不含正文」的值。 // 两条投影据此原样放行它(再摘要一次只会得到「摘要的摘要」,纯属噪音)。 func digestFrom(v any) (Digest, bool) { switch x := v.(type) { case Digest: return x, true case *Digest: if x == nil { return Digest{}, false } return *x, true default: return Digest{}, false } }