From 0b061c5fa133bf930981816d483d563231a63952 Mon Sep 17 00:00:00 2001 From: a1gaoshanlaiyige <65058021+a1gaoshanlaiyige@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:42:57 +0800 Subject: [PATCH] fix(gateway): use JSON escaping for message cache promotion Use encoding/json instead of Go string quoting when promoting message content to cached text blocks. Add regression coverage for control characters, Unicode, literal backslashes, text preservation, and idempotence. --- .../service/gateway_messages_cache.go | 6 +- .../gateway_messages_cache_json_test.go | 59 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 backend/internal/service/gateway_messages_cache_json_test.go diff --git a/backend/internal/service/gateway_messages_cache.go b/backend/internal/service/gateway_messages_cache.go index 8f7e6b5b7d..646f8b2177 100644 --- a/backend/internal/service/gateway_messages_cache.go +++ b/backend/internal/service/gateway_messages_cache.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "fmt" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" @@ -157,5 +158,8 @@ func injectCacheControlOnLastContentBlock(body []byte, idx int, msg *gjson.Resul // mustJSONString 把一个 Go string 序列化为合法 JSON string(含引号), // 用于 sjson.SetRawBytes 场景下手工拼 JSON。 func mustJSONString(s string) string { - return fmt.Sprintf("%q", s) + // Go string quoting can emit non-JSON escapes such as \x7f or \a. + // Marshaling a string cannot fail. + encoded, _ := json.Marshal(s) + return string(encoded) } diff --git a/backend/internal/service/gateway_messages_cache_json_test.go b/backend/internal/service/gateway_messages_cache_json_test.go new file mode 100644 index 0000000000..26759c4d72 --- /dev/null +++ b/backend/internal/service/gateway_messages_cache_json_test.go @@ -0,0 +1,59 @@ +//go:build unit + +package service + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestAddMessageCacheBreakpoints_JSONStringEscaping(t *testing.T) { + var controls strings.Builder + for r := rune(0); r <= 0x9f; r++ { + controls.WriteRune(r) + } + tests := []struct{ name, text string }{ + {"delete_character", "before\x7fafter"}, + {"bell_and_vertical_tab", "before\a\vafter"}, + {"nul_and_escape", "before\x00\x1bafter"}, + {"all_ascii_and_c1_controls", controls.String()}, + {"supplementary_nonprinting_rune", "before\U000e0067after"}, + {"unicode_and_json_metacharacters", "中文 😀 <>& \"quote\" \\slash\n\r\t\b\f"}, + {"literal_backslash_sequences", `literal \x7f \a \v \U000e0067`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Both the final message and the second-to-last user turn are promoted + // from strings to text blocks by the cache rewrite. + body, err := json.Marshal(map[string]any{ + "messages": []map[string]any{ + {"role": "user", "content": tt.text}, + {"role": "assistant", "content": "ack"}, + {"role": "user", "content": "next"}, + {"role": "assistant", "content": tt.text}, + }, + }) + require.NoError(t, err) + out := addMessageCacheBreakpoints(body) + // gjson accepts some malformed input, so validate with encoding/json + // before inspecting the rewritten content and cache metadata. + var decoded any + require.NoError(t, json.Unmarshal(out, &decoded)) + for _, path := range []string{"messages.0.content", "messages.3.content"} { + content := gjson.GetBytes(out, path) + require.True(t, content.IsArray()) + require.Len(t, content.Array(), 1) + block := content.Array()[0] + require.Equal(t, "text", block.Get("type").String()) + require.Equal(t, tt.text, block.Get("text").String()) + require.Equal(t, "ephemeral", block.Get("cache_control.type").String()) + require.Equal(t, "5m", block.Get("cache_control.ttl").String()) + } + require.JSONEq(t, string(out), string(addMessageCacheBreakpoints(out))) + }) + } +}