From 9ceb850d00a6cf36364c50cc374fab38a92e7eae Mon Sep 17 00:00:00 2001 From: Amir Raminfar Date: Sat, 30 May 2026 05:51:15 -0700 Subject: [PATCH] fix: strip control bytes when copying logs to clipboard (#4762) Co-authored-by: Claude Opus 4.8 (1M context) --- internal/container/sanitize.go | 19 +++++++++++++++++++ internal/container/sanitize_test.go | 25 +++++++++++++++++++++++++ internal/web/logs.go | 3 ++- 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 internal/container/sanitize.go create mode 100644 internal/container/sanitize_test.go diff --git a/internal/container/sanitize.go b/internal/container/sanitize.go new file mode 100644 index 00000000..d708017f --- /dev/null +++ b/internal/container/sanitize.go @@ -0,0 +1,19 @@ +package container + +import "strings" + +// stripControlBytes removes C0 control characters except tab (\x09), newline +// (\x0a) and carriage return (\x0d). A NUL byte terminates the clipboard string +// on Windows, dropping everything after it. +func stripControlBytes(r rune) rune { + if (r >= 0x00 && r <= 0x08) || r == 0x0b || r == 0x0c || (r >= 0x0e && r <= 0x1f) { + return -1 + } + return r +} + +// SanitizeForPlainText strips ANSI escape sequences and control bytes so log +// text is safe to copy to the clipboard or open in a text editor. +func SanitizeForPlainText(str string) string { + return strings.Map(stripControlBytes, StripANSI(str)) +} diff --git a/internal/container/sanitize_test.go b/internal/container/sanitize_test.go new file mode 100644 index 00000000..3ac3b3b3 --- /dev/null +++ b/internal/container/sanitize_test.go @@ -0,0 +1,25 @@ +package container + +import "testing" + +func TestSanitizeForPlainText(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"strips NUL bytes that truncate clipboard text on Windows", "before\x00after", "beforeafter"}, + {"strips other C0 control bytes", "a\x01b\x07c\x1fd", "abcd"}, + {"preserves tab, newline and carriage return", "a\tb\nc\r\nd", "a\tb\nc\r\nd"}, + {"removes ANSI escape sequences", "\x1b[31mred\x1b[0m text", "red text"}, + {"leaves plain text untouched", "plain log line", "plain log line"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := SanitizeForPlainText(tt.input); got != tt.expected { + t.Errorf("SanitizeForPlainText(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} diff --git a/internal/web/logs.go b/internal/web/logs.go index c9bcfd32..dfc847b3 100644 --- a/internal/web/logs.go +++ b/internal/web/logs.go @@ -197,7 +197,8 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request) } } if plainText { - fmt.Fprintf(writer, "%s\n", event.RawMessage) + // Strip ANSI and control bytes; a NUL byte truncates clipboard text on Windows + fmt.Fprintf(writer, "%s\n", container.SanitizeForPlainText(event.RawMessage)) } else if err := encoder.Encode(event); err != nil { log.Error().Err(err).Msg("error encoding log event") }