diff --git a/pkg/chat/display_name.go b/pkg/chat/display_name.go new file mode 100644 index 0000000000..8ab25398ba --- /dev/null +++ b/pkg/chat/display_name.go @@ -0,0 +1,72 @@ +package chat + +import ( + "strings" + "unicode/utf8" +) + +// MaxSanitizedFieldBytes is the canonical UTF-8-safe upper bound applied to +// every sanitized display-name and MIME-type field (see +// [SanitizeDisplayName] and the MIME sanitizer in pkg/runtime), independent +// of the separate, larger bound applied to a fully formatted placeholder or +// warning line. It exists so a single overlong provider-supplied field +// cannot itself balloon persisted metadata, session history, or prompts. +const MaxSanitizedFieldBytes = 128 + +// TruncateUTF8Bytes returns the longest prefix of s that is at most max +// bytes and still valid UTF-8 — it never splits a multi-byte rune, so the +// result is always safe to display or re-encode. Used by every canonical +// sanitizer (display name, MIME type, and formatted placeholder/warning +// text) to enforce a byte bound without corrupting non-ASCII content. +func TruncateUTF8Bytes(s string, maxBytes int) string { + if len(s) <= maxBytes { + return s + } + for maxBytes > 0 && !utf8.RuneStart(s[maxBytes]) { + maxBytes-- + } + return s[:maxBytes] +} + +// SanitizeDisplayName neutralizes a provider-supplied display name before +// it is stored in a [Document] or any other session-visible metadata. The +// name is untrusted input (e.g. Gemini's InlineData.DisplayName): it is +// never used verbatim to build a filesystem path (path-bearing consumers +// validate the requested name themselves), but it does end up in +// UI, warnings, placeholder text, and interpolated harness/prompt text +// (see pkg/runtime/harness.go's "..."-delimited blocks), so +// it must not carry control characters, path separators, traversal-like +// sequences, or angle brackets that could confuse a terminal, log line, +// forge a fake XML/tag boundary, or trick a human copy-pasting it into a +// shell. +// +// Control characters, path separators, and '<'/'>' are rewritten to '_'; +// any residual ".." sequence (which could still read as a traversal hint +// even without separators, e.g. "..name") is rewritten too. The result is +// capped at [MaxSanitizedFieldBytes] (UTF-8-safe) and trimmed of +// surrounding whitespace. An empty or all-whitespace input returns "" — +// callers are responsible for substituting their own fallback name. +func SanitizeDisplayName(name string) string { + name = strings.TrimSpace(name) + + var b strings.Builder + b.Grow(len(name)) + for _, r := range name { + switch { + case r == '/' || r == '\\': + b.WriteRune('_') + case r == '<' || r == '>': + b.WriteRune('_') + case r < 0x20 || r == 0x7f: + b.WriteRune('_') + default: + b.WriteRune(r) + } + } + sanitized := b.String() + for strings.Contains(sanitized, "..") { + sanitized = strings.ReplaceAll(sanitized, "..", "_") + } + sanitized = TruncateUTF8Bytes(strings.TrimSpace(sanitized), MaxSanitizedFieldBytes) + return strings.TrimSpace(sanitized) +} diff --git a/pkg/chat/display_name_test.go b/pkg/chat/display_name_test.go new file mode 100644 index 0000000000..c39cf828ec --- /dev/null +++ b/pkg/chat/display_name_test.go @@ -0,0 +1,77 @@ +package chat + +import ( + "strings" + "testing" + "unicode/utf8" + + "github.com/stretchr/testify/assert" +) + +func TestSanitizeDisplayName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + {"unchanged for a plain name", "cat.png", "cat.png"}, + {"path separators rewritten", "a/b\\c.png", "a_b_c.png"}, + {"control chars rewritten", "cat\x00\x01name.png", "cat__name.png"}, + {"DEL rewritten", "cat\x7fname.png", "cat_name.png"}, + {"traversal sequence neutralized", "../../etc/passwd", "____etc_passwd"}, + {"traversal without separators neutralized", "..name", "_name"}, + {"leading/trailing whitespace trimmed", " cat.png ", "cat.png"}, + {"empty input yields empty output", "", ""}, + {"whitespace-only input yields empty output", " \t\n ", ""}, + {"control chars only collapse to empty after trim", " \x00\x01 ", "__"}, + {"unicode name preserved", "猫.png", "猫.png"}, + {"angle brackets rewritten", "