From 72997b88d8c9bd9fa5bae5cb24cc85f40e01ea21 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 22 Jul 2026 11:42:47 +0800 Subject: [PATCH] fix(redis): preserve control bytes in copied values --- .../src/components/redis/RedisValueViewer.vue | 3 ++- .../redis/redisValuePresentation.spec.ts | 9 +++++++ .../src/lib/redis/redisValuePresentation.ts | 19 ++++++++++++- .../app-tests/redisValuePresentation.test.ts | 27 +++++++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/components/redis/RedisValueViewer.vue b/apps/desktop/src/components/redis/RedisValueViewer.vue index fcfd11d00..12ccfd26a 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.vue +++ b/apps/desktop/src/components/redis/RedisValueViewer.vue @@ -33,6 +33,7 @@ import { redisCollectionPageItems, redisJsonValueText, normalizeRedisJsonDraft, + redisClipboardSafeText, redisMemberCopyText, redisValueCopyText, redisValueCollectionItems, @@ -1667,7 +1668,7 @@ onBeforeUnmount(() => { {{ t("redis.editMember") }} - diff --git a/apps/desktop/src/lib/__tests__/redis/redisValuePresentation.spec.ts b/apps/desktop/src/lib/__tests__/redis/redisValuePresentation.spec.ts index bfbf2f80c..ec31e12b6 100644 --- a/apps/desktop/src/lib/__tests__/redis/redisValuePresentation.spec.ts +++ b/apps/desktop/src/lib/__tests__/redis/redisValuePresentation.spec.ts @@ -7,6 +7,7 @@ import { getRedisMemberSelectionKey, normalizeRedisJsonDraft, preferredRedisValueFormat, + redisClipboardSafeText, redisJsonValueText, redisMemberCopyText, redisValueCopyText, @@ -29,6 +30,10 @@ describe("redisValuePresentation", () => { expect(sanitizeRedisDisplayText("line1\nline2\tvalue\r\n")).toBe("line1\nline2\tvalue\r\n"); }); + it("escapes clipboard-unsafe controls without changing normal UTF-8 whitespace", () => { + expect(redisClipboardSafeText("普通文本\n下一行\t值\x00\x06\u0085结束")).toBe("普通文本\n下一行\t值\\x00\\x06\\x85结束"); + }); + it("strips utf8 c1 control bytes for display", () => { expect(sanitizeRedisDisplayText("before\u0085after")).toBe("beforeafter"); }); @@ -39,6 +44,10 @@ describe("redisValuePresentation", () => { expect(getRedisMemberSelectionKey("member", raw)).toBe(`member\n${raw}`); }); + it("copies the complete Redis member when UTF-8 text contains NUL", () => { + expect(redisMemberCopyText("before\x00after")).toBe("before\\x00after"); + }); + it("can disambiguate duplicate stream fields with an explicit identity", () => { expect(getRedisMemberSelectionKey("event", "login", "stream:1:0")).not.toBe(getRedisMemberSelectionKey("event", "login", "stream:1:1")); }); diff --git a/apps/desktop/src/lib/redis/redisValuePresentation.ts b/apps/desktop/src/lib/redis/redisValuePresentation.ts index 4ca899637..2c0072dca 100644 --- a/apps/desktop/src/lib/redis/redisValuePresentation.ts +++ b/apps/desktop/src/lib/redis/redisValuePresentation.ts @@ -249,7 +249,24 @@ export function canRenderRedisValueFormat(detail: RedisMemberDetail, format: Red } export function redisMemberCopyText(value: unknown): string { - return isRedisBlob(value) ? redisBlobRawText(value) : formatRedisMemberDetail(value).rawText; + const text = isRedisBlob(value) ? redisBlobRawText(value) : formatRedisMemberDetail(value).rawText; + return redisClipboardSafeText(text); +} + +export function redisClipboardSafeText(value: string): string { + let output = ""; + for (const ch of value) { + if (ch === "\n" || ch === "\r" || ch === "\t" || !isUtf8ControlCharacter(ch)) { + output += ch; + continue; + } + + // Native clipboard text backends may treat embedded controls such as NUL as string terminators. + // Keep ordinary whitespace intact, but copy other controls as visible byte-style escapes. + const codePoint = ch.codePointAt(0)!; + output += codePoint <= 0xff ? `\\x${codePoint.toString(16).padStart(2, "0")}` : `\\u{${codePoint.toString(16)}}`; + } + return output; } export function getRedisMemberSelectionKey(title: string, value: unknown, identity = title): string { diff --git a/packages/app-tests/redisValuePresentation.test.ts b/packages/app-tests/redisValuePresentation.test.ts index 4b065698e..443c78e39 100644 --- a/packages/app-tests/redisValuePresentation.test.ts +++ b/packages/app-tests/redisValuePresentation.test.ts @@ -12,6 +12,7 @@ import { highlightRedisJsonDetail, parseRedisJsonDetail, preferredRedisValueFormat, + redisClipboardSafeText, redisMemberCopyText, redisValueCopyText, } from "../../apps/desktop/src/lib/redis/redisValuePresentation.ts"; @@ -40,6 +41,16 @@ test("keeps plain Redis member strings unchanged", () => { assert.deepEqual(detail.availableFormats, ["utf8", "ascii", "binary", "hex", "base64"]); }); +test("keeps normal text whitespace and unicode unchanged for Redis clipboard output", () => { + assert.equal(redisClipboardSafeText("line 1\nline 2\t中文"), "line 1\nline 2\t中文"); +}); + +test("escapes clipboard-unsafe controls in Redis member copies without truncating the suffix", () => { + const serialized = 'o:\\28:"JobMessage":1:{s:7:"payload";s:11:"before\x00after";}'; + + assert.equal(redisMemberCopyText(blobFromText(serialized)), 'o:\\28:"JobMessage":1:{s:7:"payload";s:11:"before\\x00after";}'); +}); + test("formats JSON string values without changing plain strings", () => { assert.equal(formatRedisStringValue('{"id":1,"name":"Ada"}'), '{\n "id": 1,\n "name": "Ada"\n}'); assert.equal(formatRedisStringValue("plain redis value"), "plain redis value"); @@ -223,6 +234,22 @@ test("copies collection values as readable content instead of blob transport obj assert.equal(redisValueCopyText(value), '[\n {\n "field": "name",\n "value": "Ada"\n }\n]'); }); +test("keeps whole-key JSON copies JSON-escaped when members contain NUL", () => { + const value = { + key_display: "users", + key_raw: "users", + ttl: -1, + redis_type: "list", + data: { + kind: "list" as const, + items: [{ value: blobFromText("before\x00after") }], + total: 1, + }, + }; + + assert.equal(redisValueCopyText(value), '[\n "before\\u0000after"\n]'); +}); + test("copies stream entries without collapsing repeated field names", () => { const value = { key_display: "events",