Files
Cyrene_v2/internal/runtime/trace/write_side_redaction_test.go
AskaEth 542bac677d fix(trace): UpstreamError 默认不带 error.message,要带必须显式 opt-in
复审 P2 收口。上一条留的漏法(不是理论,是构造得出的):
`{"error":{"code":"invalid_request_error","message":"Invalid value: <用户原文>"}}`
—— 默认保留前 128 rune,那 128 rune 就会**原样**进 error 与日志。此前的用例只钉了
input/echo 这类 sibling 字段,没钉到 message 这一面;而 M41(embedding input 是她的
记忆正文、DashScope TTS input 是她要念的话)与 M42(渠道响应体)都把这个 helper 当
模板,默认带 message 等于把洞复制到十几处。

改法(默认安全,放宽必须写出来):

- `UpstreamError(upstream, status, contentType, body, opts ...UpstreamOption)`:
  最常用的四参调用**天然是最安全的那个**;默认只留 Upstream/Status/ContentType/
  BodyLen/BodySHA256/Code/Type;
- 新增 `WithUpstreamMessage()` 作为显式开关:带 message 时**必须**过 RedactText
  (凭据形状打码 + 压平空白)再截到 maxUpstreamMessageRunes,并在注释里写明
  「只有在你确认该上游不会回显用户内容时才可用」;
- 文件头写清取舍:**宁可少一条人话提示,也不让她的原话进日志**;排障靠
  status + code + type + bodyLen + sha256 通常足够分类(400 还是 429、是
  model_not_found 还是 context_length_exceeded、是不是同一份报文),细节要看时
  报文在调用点上下文里还在。

测试(可变异打红):
- 新增 TestUpstreamError_DefaultDropsMessage:message 里塞哨兵的 400 ⇒ 默认构造下
  error 与 slog 输出都搜不到哨兵,Message 为空,而 status/code/type/bodyLen/sha256
  仍可辨;Attrs() 也不旁路带出;
- 新增 TestUpstreamError_OptInMessageIsSanitized:opt-in 后 message 被截到 128 rune
  且凭据被打码(超长 + 带 token 的 message);
- 既有 TestUpstreamError_DropsEchoedBody 补一条「默认 Message 为空」;
- 变异验证:把默认构造改回带 message ⇒ 前两条变红;让 opt-in 跳过 RedactText ⇒
  opt-in 那条变红。

回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿(backthink 的两条
2s 定时用例在满并发下偶发超时,单元重跑与整仓重跑均绿,与本改动无关:该包不引用
trace);trace 包另跑 -race 绿。只动 upstream.go 与其测试,白名单/session/loop/
orchestration 未动。
2026-10-26 13:31:11 +08:00

498 lines
20 KiB
Go
Raw Permalink 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.
package trace
import (
"bytes"
"encoding/json"
"errors"
"log/slog"
"strings"
"testing"
)
// 本文件钉住 M40 的**写侧**(stderr JSONL)边界:写下去的每一行都可以被
// `tail`/采集/粘进工单,因此它只允许携带脱敏后的投影 —— 正文、工具结果原文、
// 子代理回复原文、上游响应体原文,一个字节都不许出现。
//
// 🔴 断言一律打在**落盘输出/序列化结果**上(JSONL 字节、slog 输出),
// 不打在 error.Error() 的措辞上:措辞会随实现变,泄漏不会。
// sentinelToolBody 是「工具结果正文」的哨兵:只要它出现在落盘里,就说明写侧
// 又把正文抄了一遍。
const sentinelToolBody = "SENTINEL-TOOL-RESULT-9f3a2b-一段不该落盘的正文"
// writeRecords 把若干 span 记录经 JSONRecorder 写进缓冲区并解码回来。它直接用
// 记录(而不是跑一次真循环)是为了让「写侧投影」这件事在 trace 包内自证;
// 端到端那条链由 loop/orchestration 两个包的测试钉住。
func writeRecords(t *testing.T, recs ...SpanRecord) ([]SpanRecord, string) {
t.Helper()
var buf bytes.Buffer
rec := NewJSONRecorder(&buf)
for _, r := range recs {
if err := rec.WriteSpan(r); err != nil {
t.Fatalf("WriteSpan: %v", err)
}
}
return decodeLines(t, buf.Bytes()), buf.String()
}
// TestJSONRecorder_KeepsSessionReadableDropsBody 是「日志里 session 可读、正文仍
// 不可见」**同一条断言**(主人 2026-10-26 裁定放行 session):
//
// - session 是业务标识、不是正文,进白名单后必须**原文可读**(排障要把一条
// stderr 日志对回某个会话;变成 Digest 就失去用途);
// - 同一 span 里的正文(result / text)仍必须落不到盘上 —— 这一条防的是
// 「顺手放宽了别的东西」:白名单加一个键,正文的边界不能跟着动。
func TestJSONRecorder_KeepsSessionReadableDropsBody(t *testing.T) {
t.Parallel()
const session = "web#lane-2"
rec := spanRec("sess1", "", "tool.fs.read", SpanKindTool, SpanStatusOK, map[string]any{
"session": session,
"result": sentinelToolBody,
"text": sentinelToolBody,
})
recs, raw := writeRecords(t, rec)
if !strings.Contains(raw, `"session":"`+session+`"`) {
t.Fatalf("JSONL 里 session 不可读(它是排障对回会话的唯一线索):%s", raw)
}
if strings.Contains(raw, sentinelToolBody) {
t.Fatalf("放行 session 的同时正文也落盘了:%s", raw)
}
got := byID(recs, "sess1")
if got.Props["session"] != session {
t.Fatalf("session prop = %v, want 原文 %q", got.Props["session"], session)
}
for _, key := range []string{"result", "text"} {
if _, ok := got.Props[key]; ok {
t.Fatalf("表外 prop %q 落盘了:%+v", key, got.Props)
}
if d := omittedEntry(t, got, key); d.SHA256 == "" || d.Bytes == 0 {
t.Fatalf("omitted 里没有 %q 的体积/指纹:%+v", key, d)
}
}
}
// TestJSONRecorder_DropsRawContentAndKeepsDigest 是写侧投影的主钉:
// - 表外的 `result` / `text`(正文形态)绝不落盘;
// - 白名单键仍落盘(图上与日志里要能看见 tool / error 之类的元数据);
// - 被丢弃的键折进 omitted,留下 bytes/runes/sha256 —— 于是「同一次工具调用
// 成功还是失败、输出多大」仍可分辨(这是「脱敏不能把 span 变得没用」的验收点);
// - 字符串值一律过 RedactText(凭据形状打码)。
func TestJSONRecorder_DropsRawContentAndKeepsDigest(t *testing.T) {
t.Parallel()
okRec := spanRec("s1", "", "tool.fs.read", SpanKindTool, SpanStatusOK, map[string]any{
"tool": "fs.read",
"result": sentinelToolBody,
"result_summary": "SENTINEL-TOOL",
"result_digest": DigestOf(sentinelToolBody),
})
badRec := spanRec("s2", "", "tool.fs.read", SpanKindTool, SpanStatusError, map[string]any{
"tool": "fs.read",
"result": strings.Repeat("错", 400),
"error": "read failed: token=abcdef1234567890",
"text": sentinelToolBody,
"subagent": "sa-1",
})
recs, raw := writeRecords(t, okRec, badRec)
if strings.Contains(raw, sentinelToolBody) {
t.Fatalf("写侧 JSONL 里出现了工具结果/子代理回复原文:%s", raw)
}
if strings.Contains(raw, "abcdef1234567890") {
t.Fatalf("写侧 JSONL 里出现了凭据原文(字符串值没走 RedactText):%s", raw)
}
if n := strings.Count(strings.TrimSpace(raw), "\n") + 1; n != 2 {
t.Fatalf("落盘行数 = %d, want 2(一行一个 span): %s", n, raw)
}
okSpan, badSpan := byID(recs, "s1"), byID(recs, "s2")
// 表外键不得以原值出现。
for _, key := range []string{"result", "text"} {
if _, ok := badSpan.Props[key]; ok {
t.Fatalf("表外 prop %q 落盘了:%+v", key, badSpan.Props)
}
}
// 白名单键照旧落盘:tool / result_summary / error / subagent。
if okSpan.Props["tool"] != "fs.read" || okSpan.Props["result_summary"] != "SENTINEL-TOOL" {
t.Fatalf("白名单键没落盘:%+v", okSpan.Props)
}
if _, ok := badSpan.Props["subagent"]; !ok {
t.Fatalf("白名单键 subagent 没落盘:%+v", badSpan.Props)
}
if got, _ := badSpan.Props["error"].(string); !strings.Contains(got, redactedMarker) {
t.Fatalf("error prop = %v,凭据没被打码", badSpan.Props["error"])
}
// 诊断线索:成功/失败可分辨(status + Digest),输出大小可分辨(bytes)。
if okSpan.Status != SpanStatusOK || badSpan.Status != SpanStatusError {
t.Fatalf("status = %q/%q,成功与失败必须仍可分辨", okSpan.Status, badSpan.Status)
}
okDropped := omittedEntry(t, okSpan, "result")
badDropped := omittedEntry(t, badSpan, "result")
if okDropped.Bytes != len(sentinelToolBody) {
t.Fatalf("omitted.result.bytes = %d, want %d(输出大小必须仍可读)", okDropped.Bytes, len(sentinelToolBody))
}
if badDropped.Bytes != len(strings.Repeat("错", 400)) {
t.Fatalf("omitted.result.bytes = %d, want %d", badDropped.Bytes, len(strings.Repeat("错", 400)))
}
if okDropped.SHA256 == "" || okDropped.SHA256 == badDropped.SHA256 {
t.Fatalf("omitted.result.sha256 = %q/%q,want 两份不同内容的指纹不同", okDropped.SHA256, badDropped.SHA256)
}
// Digest 型 prop(生产侧明确声明「这不是正文」)原样落盘,且不再被再摘要一次。
if got, ok := okSpan.Props["result_digest"].(map[string]any); !ok {
t.Fatalf("result_digest 没落盘:%+v", okSpan.Props)
} else if int(got["bytes"].(float64)) != len(sentinelToolBody) {
t.Fatalf("result_digest.bytes = %v, want %d", got["bytes"], len(sentinelToolBody))
}
}
// TestJSONRecorder_SubagentReplyNeverLands 钉住子代理回复这条路径:整段回复
// 曾经以 `text` prop 与 `summary` 事件值的形式原样落盘(M40 修掉的最严重一条)。
// 现在 span 上只有 text_digest,事件上只有 chars/sha256。
func TestJSONRecorder_SubagentReplyNeverLands(t *testing.T) {
t.Parallel()
const reply = "SENTINEL-SUBAGENT-REPLY-77c1-她说的整段话"
rec := spanRec("sa", "", "subagent.sa-1", SpanKindSubagent, SpanStatusOK, map[string]any{
"text": reply,
"text_digest": DigestOf(reply),
})
rec.Events = []Event{{Type: "subagent.done", Props: map[string]any{
"status": "done",
"steps": 2,
"chars": len([]rune(reply)),
"sha256": DigestOf(reply).SHA256,
"text": reply,
}}}
recs, raw := writeRecords(t, rec)
if strings.Contains(raw, reply) {
t.Fatalf("写侧 JSONL 里出现了子代理回复原文:%s", raw)
}
got := byID(recs, "sa")
if _, ok := got.Props["text"]; ok {
t.Fatalf("子代理 span 的 text prop 落盘了:%+v", got.Props)
}
if d, ok := got.Props["text_digest"].(map[string]any); !ok || d["sha256"] == "" {
t.Fatalf("text_digest 缺失:%+v", got.Props)
}
if n := len(got.Events); n != 1 {
t.Fatalf("events = %d, want 1", n)
}
if ev := got.Events[0]; ev.Props["text"] != nil {
// 正文键的值必须已被换成 Digest(键留在原处,值不含正文)。
d, ok := ev.Props["text"].(map[string]any)
if !ok || d["sha256"] == "" {
t.Fatalf("事件里的正文键没折成 Digest:%+v", ev.Props)
}
}
}
// TestUpstreamError_DropsEchoedBody 是共享 helper 的行为钉(M41/M42 的用法模板):
// 一个**回显请求正文**的 422 报文,经 UpstreamError 收敛后,错误文本与日志里都
// 搜不到那段回显,但 status / code / type 仍可辨。
func TestUpstreamError_DropsEchoedBody(t *testing.T) {
t.Parallel()
const echoedInput = "SENTINEL-USER-INPUT-5d2e-用户在聊天框里说的话"
body := []byte(`{"error":{"code":"invalid_request_error","type":"invalid_request",` +
`"message":"Invalid value for 'messages[0].content'."},` +
`"param":"messages[0].content","input":"` + echoedInput + `",` +
`"echo":{"messages":[{"role":"user","content":"` + echoedInput + `"}]}}`)
err := UpstreamError("api.openai.example", 422, "application/json; charset=utf-8", body)
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, nil))
log.Error("upstream call failed", "err", err)
text := err.Error()
for _, surface := range []string{text, buf.String()} {
if strings.Contains(surface, echoedInput) {
t.Fatalf("响应体回显的输入原样出现了:%s", surface)
}
if strings.Contains(surface, "messages[0].content") && strings.Contains(surface, "role") {
t.Fatalf("响应体的非白名单字段被带出来了:%s", surface)
}
}
for _, want := range []string{"status=422", "invalid_request_error", "type=invalid_request", "api.openai.example"} {
if !strings.Contains(text, want) {
t.Fatalf("error = %q,want 含 %q(诊断信息不能一起丢掉)", text, want)
}
}
if !errors.Is(err, ErrUpstream) {
t.Fatalf("errors.Is(err, ErrUpstream) = false;降级链无法按类别判断")
}
info, ok := err.(*UpstreamErrorInfo)
if !ok {
t.Fatalf("err 类型 = %T, want *UpstreamErrorInfo", err)
}
if info.BodyLen != len(body) || info.ContentType != "application/json" || info.BodySHA256 == "" {
t.Fatalf("info = %+v,want bodyLen/contentType/sha256 作为诊断线索", info)
}
// 默认构造连上游那句人话(error.message)也不带:它的措辞里就可能嵌着用户内容
// (这个报文里是 'messages[0].content'),默认安全口径见上游文件头注释。
if info.Message != "" {
t.Fatalf("默认构造的 Message = %q,want 空", info.Message)
}
// Attrs() 是日志侧的第二出口,必须与 Error() 同源(同样不含正文)。
attrs := info.Attrs()
if joined := strings.TrimSpace(strings.Join(anyToStrings(attrs), " ")); strings.Contains(joined, echoedInput) {
t.Fatalf("Attrs() 带出了回显正文:%s", joined)
}
}
// TestUpstreamError_NonJSONBodyKeepsOnlyMeta 钉住「非 JSON 也绝不回落原文」:
// HTML 错误页 / 代理错误页 / 被截断的报文只留 status 与体积指纹。
func TestUpstreamError_NonJSONBodyKeepsOnlyMeta(t *testing.T) {
t.Parallel()
const echoed = "SENTINEL-PROXY-ECHO-0b71"
body := []byte("<html><body>upstream said: " + echoed + "</body></html>")
err := UpstreamError("", 502, "text/html", body)
if strings.Contains(err.Error(), echoed) {
t.Fatalf("非 JSON 响应体原文进了错误:%s", err.Error())
}
if !strings.Contains(err.Error(), "status=502") || !strings.Contains(err.Error(), "body_bytes=") {
t.Fatalf("error = %q,want status 与 body_bytes 仍在", err.Error())
}
}
// TestUpstreamError_DefaultDropsMessage 是 P2 收口的行为钉(M41/M42 的模板):
// 上游把**用户内容**拼进 error.message 时,**默认构造**必须一个字都不带出来。
// 这不是理论担心 —— `{"error":{"message":"Invalid value: <用户原文>"}}` 是真实
// 存在的写法,默认保留前 128 rune 就够把一段原话抄进日志。
//
// 断言同时要求「诊断仍可辨」:status / code / type / bodyLen / bodySHA256 都还在,
// 它们才是分类与降级的依据。
func TestUpstreamError_DefaultDropsMessage(t *testing.T) {
t.Parallel()
const sentinel = "SENTINEL-USER-INPUT-IN-MESSAGE-3c9f-她在输入框里写的话"
body := []byte(`{"error":{"code":"invalid_request_error","type":"invalid_request",` +
`"message":"Invalid value: ` + sentinel + `"}}`)
err := UpstreamError("api.example.com", 400, "application/json", body)
var logBuf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&logBuf, nil))
log.Error("upstream call failed", "err", err)
for _, surface := range []string{err.Error(), logBuf.String()} {
if strings.Contains(surface, sentinel) {
t.Fatalf("默认构造把 error.message 里的用户内容带出来了:%s", surface)
}
}
info := err.(*UpstreamErrorInfo)
if info.Message != "" {
t.Fatalf("默认构造的 Message = %q,want 空(message 必须显式 opt-in)", info.Message)
}
for _, want := range []string{"status=400", "invalid_request_error", "type=invalid_request", "body_bytes="} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %q,want 含 %q(丢掉 message 不等于丢掉诊断)", err.Error(), want)
}
}
if info.BodyLen != len(body) || info.BodySHA256 == "" {
t.Fatalf("info = %+v,want bodyLen/bodySHA256 仍用于相关性比对", info)
}
// Attrs() 同样不得旁路带出 message。
if joined := strings.Join(anyToStrings(info.Attrs()), " "); strings.Contains(joined, sentinel) {
t.Fatalf("Attrs() 带出了 message 里的用户内容:%s", joined)
}
}
// TestUpstreamError_OptInMessageIsSanitized 钉住 opt-in 侧的边界:显式
// WithUpstreamMessage 之后拿到的 message 仍**必须**过 RedactText(凭据打码)并
// 截断到 maxUpstreamMessageRunes —— opt-in 放宽的是「带不带人话」,不是「带多脏」。
func TestUpstreamError_OptInMessageIsSanitized(t *testing.T) {
t.Parallel()
const secret = "abcdef1234567890"
// 凭据放在开头(截断窗口内),后面接一大段正文,验证「打码 + 截断」两件事。
message := "lookup failed: token=" + secret + " " + strings.Repeat("详", 400)
body := []byte(`{"error":{"code":"bad","type":"invalid_request","message":"` + message + `"}}`)
err := UpstreamError("p", 400, "application/json", body, WithUpstreamMessage())
info := err.(*UpstreamErrorInfo)
if info.Message == "" {
t.Fatal("opt-in 之后 Message 仍为空")
}
if strings.Contains(info.Message, secret) {
t.Fatalf("opt-in 的 message 带着凭据原文:%q", info.Message)
}
if !strings.Contains(info.Message, redactedMarker) {
t.Fatalf("opt-in 的 message = %q,want 含 %s(必须走 RedactText)", info.Message, redactedMarker)
}
if n := len([]rune(info.Message)); n > maxUpstreamMessageRunes+1 {
t.Fatalf("message 长度 = %d rune,超过上限 %d", n, maxUpstreamMessageRunes)
}
if !strings.HasPrefix(info.Message, "lookup failed:") {
t.Fatalf("message = %q,want 保留上游诊断语的前缀", info.Message)
}
// opt-in 只放宽 message:凭据不许旁路进 Error() 的其它字段。
if strings.Contains(err.Error(), secret) {
t.Fatalf("opt-in 后 Error() 带着凭据原文:%s", err.Error())
}
}
// TestHostOf_DropsPathQueryAndUserInfo 钉住「日志里的 URL 只有来源」。
func TestHostOf_DropsPathQueryAndUserInfo(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want string
}{
{"普通 URL", "https://cdn.example.com/a/b?token=SECRET&x=1", "https://cdn.example.com"},
{"带端口", "http://api.example.com:8443/v1/chat", "http://api.example.com:8443"},
{"带 user:pass", "https://user:pass@h/x", "https://[redacted]@h"},
{"相对路径", "/a/b?token=SECRET", ""},
{"畸形串", "http://[::1", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := HostOf(tc.in)
if got != tc.want {
t.Fatalf("HostOf(%q) = %q, want %q", tc.in, got, tc.want)
}
for _, leak := range []string{"SECRET", "pass", "user", "/a/"} {
if strings.Contains(got, leak) {
t.Fatalf("HostOf(%q) = %q 泄漏了 %q", tc.in, got, leak)
}
}
})
}
}
// TestMaskQuery_MasksCredentialParams 钉住「查询串里的凭据值被打码」:OAuth token、
// 预签名 URL 的签名、API key 一律不明文出现,其余参数保持可读。
func TestMaskQuery_MasksCredentialParams(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
wantHas []string
leaks []string
}{
{
name: "access_token 打码",
in: "https://api.example.com/v1/x?access_token=xxxSECRETxxx&page=2",
wantHas: []string{"access_token=***", "page=2"},
leaks: []string{"xxxSECRETxxx"},
},
{
name: "user:pass 整体打码",
in: "https://user:pass@h/x?api_key=k3ySECRET&sig=SIGSECRET",
wantHas: []string{"[redacted]@h", "api_key=***"},
leaks: []string{"pass", "k3ySECRET", "SIGSECRET"},
},
{
name: "片段里的 token 同样打码",
in: "https://h/cb#access_token=FRAGSECRET&state=ok",
wantHas: []string{"access_token=***", "state=ok"},
leaks: []string{"FRAGSECRET"},
},
{
name: "非 URL 不回原串",
in: "token=RAW&x=1",
leaks: []string{"RAW"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := MaskQuery(tc.in)
for _, want := range tc.wantHas {
if !strings.Contains(got, want) {
t.Fatalf("MaskQuery(%q) = %q, want 含 %q", tc.in, got, want)
}
}
for _, leak := range tc.leaks {
if strings.Contains(got, leak) {
t.Fatalf("MaskQuery(%q) = %q 泄漏了 %q", tc.in, got, leak)
}
}
})
}
}
// TestJSONRecorder_DoesNotMutateCallerRecord 钉住投影是「拷贝」而非「就地改」:
// 调用方(例如 gateway 的 trace 环形缓冲出口)仍拿得到完整记录。
func TestJSONRecorder_DoesNotMutateCallerRecord(t *testing.T) {
t.Parallel()
rec := spanRec("m1", "", "tool.x", SpanKindTool, SpanStatusOK, map[string]any{"result": sentinelToolBody})
if _, raw := writeRecords(t, rec); strings.Contains(raw, sentinelToolBody) {
t.Fatalf("写侧泄漏了原文:%s", raw)
}
if got, _ := rec.Props["result"].(string); got != sentinelToolBody {
t.Fatalf("调用方记录被就地改写了:%+v", rec.Props)
}
}
// TestSanitizeRecordKeepsIdentity 钉住「脱敏不动身份」:id/parent/name/kind/status
// 与时间戳原样保留,否则落盘的链路就串不起来了。
func TestSanitizeRecord_KeepsIdentity(t *testing.T) {
t.Parallel()
rec := spanRec("c1", "p1", "llm.step2", SpanKindLLM, SpanStatusError, map[string]any{"prompt": "正文"})
out := SanitizeRecord(rec)
if out.ID != rec.ID || out.ParentID != rec.ParentID || out.Name != rec.Name ||
out.Kind != rec.Kind || out.Status != rec.Status ||
!out.StartedAt.Equal(rec.StartedAt) || !out.EndedAt.Equal(rec.EndedAt) {
t.Fatalf("sanitizeRecord 改动了身份字段:%+v vs %+v", out, rec)
}
if _, ok := out.Props["prompt"]; ok {
t.Fatalf("表外 prop prompt 落盘了:%+v", out.Props)
}
if _, err := json.Marshal(out); err != nil {
t.Fatalf("脱敏后的记录不可编码:%v", err)
}
}
// byID 在解码后的记录里按 id 找一条,找不到即失败。
func byID(recs []SpanRecord, id string) SpanRecord {
for _, r := range recs {
if r.ID == id {
return r
}
}
panic("span record not found: " + id)
}
// omittedEntry 反读某条记录 omitted 表里被丢弃的键的 Digest。
func omittedEntry(t *testing.T, rec SpanRecord, key string) Digest {
t.Helper()
omitted, ok := rec.Props[omittedPropKey].(map[string]any)
if !ok {
t.Fatalf("props 里没有 %q 表:%+v", omittedPropKey, rec.Props)
}
entry, ok := omitted[key].(map[string]any)
if !ok {
t.Fatalf("%s 表里没有 %q 的 Digest:%+v", omittedPropKey, key, omitted)
}
return Digest{
Bytes: int(entry["bytes"].(float64)),
Runes: int(entry["runes"].(float64)),
SHA256: entry["sha256"].(string),
}
}
// anyToStrings 把 Attrs() 的键值对压成字符串切片,便于做「搜不到」断言。
func anyToStrings(attrs []any) []string {
out := make([]string, 0, len(attrs))
for _, a := range attrs {
out = append(out, strings.Join(strings.Fields(toString(a)), " "))
}
return out
}
// toString 渲染一个 Attr 值(只用于测试断言)。
func toString(v any) string {
switch x := v.(type) {
case string:
return x
case nil:
return ""
default:
b, err := json.Marshal(x)
if err != nil {
return ""
}
return string(b)
}
}