fix(redis): preserve control bytes in copied values

This commit is contained in:
t8y2 2026-07-22 11:42:47 +08:00
parent 330d27d718
commit 72997b88d8
4 changed files with 56 additions and 2 deletions

View File

@ -33,6 +33,7 @@ import {
redisCollectionPageItems,
redisJsonValueText,
normalizeRedisJsonDraft,
redisClipboardSafeText,
redisMemberCopyText,
redisValueCopyText,
redisValueCollectionItems,
@ -1667,7 +1668,7 @@ onBeforeUnmount(() => {
<Pencil class="h-4 w-4" />
{{ t("redis.editMember") }}
</Button>
<Button variant="outline" @click="copyText(detailTextForFormat(selectedMemberDetail, memberValueView))">
<Button variant="outline" @click="copyText(redisClipboardSafeText(detailTextForFormat(selectedMemberDetail, memberValueView)))">
<Copy class="h-4 w-4" />
{{ t("redis.copyMember") }}
</Button>

View File

@ -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"));
});

View File

@ -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 {

View File

@ -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",