Files
multica/server/internal/util/text.go
2026-09-20 14:57:49 +08:00

177 lines
5.8 KiB
Go

package util
import (
"fmt"
"sort"
"strings"
"unicode/utf8"
)
// UnescapeBackslashEscapes decodes the common backslash escape sequences
// (\n, \r, \t, \\) that LLM agents routinely emit as 4-character literals
// because Python/JSON-style string conventions are their default. The same
// helper is used by the CLI to fix bash-double-quote bodies (where the shell
// doesn't expand \n) and by the daemon-task completion path to fix raw agent
// stdout that arrives with literal `\n\n` between paragraphs.
//
// Only \n / \r / \t / \\ are decoded. Other escape sequences (\d, \w, \s,
// \u, \0, \", etc.) pass through verbatim so regex literals and printf
// format strings survive without surprise mutation. Callers that need the
// literal 4-char sequence intact should bypass this helper entirely (the CLI
// exposes --content-stdin / --description-stdin for that case).
func UnescapeBackslashEscapes(s string) string {
if !strings.ContainsRune(s, '\\') {
return s
}
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if c == '\\' && i+1 < len(s) {
switch s[i+1] {
case 'n':
b.WriteByte('\n')
i++
continue
case 'r':
b.WriteByte('\r')
i++
continue
case 't':
b.WriteByte('\t')
i++
continue
case '\\':
b.WriteByte('\\')
i++
continue
}
}
b.WriteByte(c)
}
return b.String()
}
// sanitizeJSONMaxDepth bounds SanitizeJSONForPostgres's recursion. Task tool
// input is a handful of levels deep in practice; anything past this is either
// pathological or hostile, and is dropped rather than walked so a deeply
// nested payload cannot turn a persistence guard into a stack overflow.
const sanitizeJSONMaxDepth = 32
// SanitizeTextForPostgres makes an arbitrary externally-produced string safe to
// persist in a PostgreSQL TEXT or JSONB column.
//
// Two distinct failure modes, either of which aborts the entire INSERT and
// rolls the surrounding transaction back:
//
// - Embedded NUL (0x00). PostgreSQL rejects it in TEXT with SQLSTATE 22021
// ("invalid byte sequence for encoding UTF8"), and in JSONB with 22P05
// ("unsupported Unicode escape sequence") once encoding/json has escaped it.
// Removed outright: it has no useful rendering, and the caller wants the
// surrounding diagnostic far more than it wants an invisible byte.
// - Any other invalid UTF-8 byte sequence — a byte-bounded tail that cut a
// multi-byte rune in half, a Windows-1252 smart quote, raw binary from a
// child process that crashed. Replaced with U+FFFD so the text keeps its
// shape and a reader can see that something was there.
//
// IMPORTANT: strings.ToValidUTF8 alone does NOT cover the NUL case — 0x00 is a
// perfectly valid UTF-8 encoding of U+0000, so it passes through untouched.
// The explicit ReplaceAll is load-bearing. Do not "simplify" it away; that is
// exactly the gap that let GH #7098 wedge tasks in 'running'.
func SanitizeTextForPostgres(s string) string {
// Overwhelmingly the common case: leave ordinary text completely alone.
if !strings.ContainsRune(s, 0) && utf8.ValidString(s) {
return s
}
return strings.ReplaceAll(strings.ToValidUTF8(s, "\uFFFD"), "\x00", "")
}
// SanitizeJSONForPostgres walks a decoded JSON value — the `any` tree
// encoding/json produces — and applies SanitizeTextForPostgres to every string
// inside it, object keys included: a poisoned key breaks the INSERT exactly
// like a poisoned value does.
//
// This exists because sanitizing only the top-level string fields of a request
// is not enough when one of those fields is itself a JSONB blob (agent tool
// input, for instance), where the offending byte can sit at any depth.
//
// Subtrees deeper than sanitizeJSONMaxDepth are replaced with nil rather than
// walked. That is a deliberate, bounded loss: the alternative is letting an
// adversarial payload dictate our recursion depth.
func SanitizeJSONForPostgres(v any) any {
return sanitizeJSONValue(v, 0)
}
func sanitizeJSONValue(v any, depth int) any {
if depth > sanitizeJSONMaxDepth {
return nil
}
switch t := v.(type) {
case string:
return SanitizeTextForPostgres(t)
case map[string]any:
out := make(map[string]any, len(t))
for k, safeKey := range sanitizePostgresJSONMapKeys(t) {
out[safeKey] = sanitizeJSONValue(t[k], depth+1)
}
return out
case []any:
out := make([]any, len(t))
for i, val := range t {
out[i] = sanitizeJSONValue(val, depth+1)
}
return out
default:
// Numbers, bools, nil — nothing a TEXT/JSONB column can choke on.
return v
}
}
// sanitizePostgresJSONMapKeys returns a stable one-to-one mapping from raw
// object keys to PostgreSQL-safe keys. Unchanged keys reserve their spelling
// first; transformed keys that would collide receive the first free numeric
// suffix instead of silently overwriting another entry.
func sanitizePostgresJSONMapKeys(values map[string]any) map[string]string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
mapped := make(map[string]string, len(keys))
used := make(map[string]struct{}, len(keys))
nextSuffix := make(map[string]int)
changed := make([]string, 0, len(keys))
for _, key := range keys {
candidate := SanitizeTextForPostgres(key)
if candidate == key {
mapped[key] = key
used[key] = struct{}{}
continue
}
changed = append(changed, key)
}
for _, key := range changed {
base := SanitizeTextForPostgres(key)
candidate := base
if _, exists := used[candidate]; exists {
suffix := nextSuffix[base]
if suffix == 0 {
suffix = 2
}
for {
candidate = fmt.Sprintf("%s#%d", base, suffix)
if _, exists := used[candidate]; !exists {
nextSuffix[base] = suffix + 1
break
}
suffix++
}
}
mapped[key] = candidate
used[candidate] = struct{}{}
}
return mapped
}