From 4bf7551b82879bc097b9d25a8c103e5d26899625 Mon Sep 17 00:00:00 2001 From: Zhuxing Date: Fri, 21 Aug 2026 12:21:37 +0800 Subject: [PATCH] fix(redis): support legacy pickle and valid XML tags --- .../src/components/redis/RedisValueViewer.vue | 377 +++++++--- apps/desktop/src/i18n/locales/en.ts | 6 + apps/desktop/src/i18n/locales/es.ts | 6 + apps/desktop/src/i18n/locales/it.ts | 6 + apps/desktop/src/i18n/locales/ja.ts | 6 + apps/desktop/src/i18n/locales/ko.ts | 6 + apps/desktop/src/i18n/locales/pt-BR.ts | 6 + apps/desktop/src/i18n/locales/zh-CN.ts | 6 + apps/desktop/src/i18n/locales/zh-TW.ts | 6 + .../redis/RedisValueViewer.redisJson.spec.ts | 6 +- .../redis/redisValuePresentation.spec.ts | 55 +- .../lib/redis/codec/__tests__/base64.spec.ts | 23 + .../__tests__/compression.spec.ts} | 2 +- .../lib/redis/codec/__tests__/msgpack.spec.ts | 16 + .../codec/__tests__/phpSerialize.spec.ts | 40 ++ .../lib/redis/codec/__tests__/pickle.spec.ts | 78 +++ .../redis/codec/__tests__/protobuf.spec.ts | 40 ++ apps/desktop/src/lib/redis/codec/base64.ts | 16 + .../compression.ts} | 0 apps/desktop/src/lib/redis/codec/index.ts | 7 + .../lib/redis/{ => codec}/javaSerialized.ts | 0 apps/desktop/src/lib/redis/codec/msgpack.ts | 55 ++ .../src/lib/redis/codec/phpSerialize.ts | 158 +++++ apps/desktop/src/lib/redis/codec/pickle.ts | 651 ++++++++++++++++++ apps/desktop/src/lib/redis/codec/protobuf.ts | 116 ++++ .../src/lib/redis/redisValuePresentation.ts | 193 +++++- .../app-tests/redisValuePresentation.test.ts | 30 +- 27 files changed, 1778 insertions(+), 133 deletions(-) create mode 100644 apps/desktop/src/lib/redis/codec/__tests__/base64.spec.ts rename apps/desktop/src/lib/redis/{__tests__/redisCompression.spec.ts => codec/__tests__/compression.spec.ts} (99%) create mode 100644 apps/desktop/src/lib/redis/codec/__tests__/msgpack.spec.ts create mode 100644 apps/desktop/src/lib/redis/codec/__tests__/phpSerialize.spec.ts create mode 100644 apps/desktop/src/lib/redis/codec/__tests__/pickle.spec.ts create mode 100644 apps/desktop/src/lib/redis/codec/__tests__/protobuf.spec.ts create mode 100644 apps/desktop/src/lib/redis/codec/base64.ts rename apps/desktop/src/lib/redis/{redisCompression.ts => codec/compression.ts} (100%) create mode 100644 apps/desktop/src/lib/redis/codec/index.ts rename apps/desktop/src/lib/redis/{ => codec}/javaSerialized.ts (100%) create mode 100644 apps/desktop/src/lib/redis/codec/msgpack.ts create mode 100644 apps/desktop/src/lib/redis/codec/phpSerialize.ts create mode 100644 apps/desktop/src/lib/redis/codec/pickle.ts create mode 100644 apps/desktop/src/lib/redis/codec/protobuf.ts diff --git a/apps/desktop/src/components/redis/RedisValueViewer.vue b/apps/desktop/src/components/redis/RedisValueViewer.vue index d77939ed82..629860b86b 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.vue +++ b/apps/desktop/src/components/redis/RedisValueViewer.vue @@ -46,10 +46,18 @@ import { redisValueCollectionItems, redisValueCollectionScanCursor, redisValueCollectionTotal, + isDecompressCodec, + isJsonDerivedView, + isRedisValueCodec, + isStructuredCodec, + jsonToXmlText, + jsonToYamlText, + REDIS_VALUE_CODEC_ORDER, type RedisCollectionItem, + type RedisValueCodec, type RedisValueFormat, } from "@/lib/redis/redisValuePresentation"; -import { decompressRedisValue, isGzipMagic, type RedisDecompressAlgorithm } from "@/lib/redis/redisCompression"; +import { decompressRedisValue, decodeBase64RedisValue, decodePickle, decodeProtobuf, isGzipMagic, type RedisDecompressAlgorithm } from "@/lib/redis/codec"; import { canFullHighlightRedisText, findRedisTextMatches, nextRedisSearchMatchIndex, REDIS_VALUE_SEARCH_MATCH_LIMIT, renderRedisTextSearchHtml, redisValueSearchStatus } from "@/lib/redis/redisValueSearch"; import TextContentSearchBar from "@/components/common/TextContentSearchBar.vue"; import { decodeJsonUnicodeEscapes, formatJsonSource, mapDisplayToRaw } from "@/lib/common/safeJsonFormat"; @@ -80,6 +88,7 @@ const emit = defineEmits<{ deleted: [keyRaw: string]; loaded: [value: RedisValue const REDIS_JSON_WRAP_STORAGE_KEY = "dbx-redis-json-word-wrap"; const REDIS_JSON_UNICODE_MODE_STORAGE_KEY = "dbx-redis-json-unicode-mode"; const REDIS_VALUE_FORMAT_STORAGE_KEY = "dbx-redis-value-format"; +const REDIS_VALUE_CODEC_STORAGE_KEY = "dbx-redis-value-codec"; // Versioned after moving the setting into the refresh menu so the previous // always-on default does not carry into the new manual-refresh default. const REDIS_AUTO_REFRESH_ENABLED_STORAGE_KEY = "dbx-redis-auto-refresh-enabled-v2"; @@ -176,6 +185,8 @@ const zsetScoreWidth = ref(220); const isResizingZsetColumns = ref(false); const stringValueView = ref(readPreferredRedisValueFormat()); const memberValueView = ref(readPreferredRedisValueFormat()); +const stringValueCodec = ref(readPreferredRedisValueCodec()); +const memberValueCodec = ref(readPreferredRedisValueCodec()); const redisJsonWordWrap = ref(readRedisJsonWordWrap()); const redisJsonUnicodeMode = ref(readRedisJsonUnicodeMode()); const redisJsonDecoded = computed(() => redisJsonUnicodeMode.value === "decoded"); @@ -205,13 +216,13 @@ async function runDecompress(bytes: Uint8Array, algorithm?: RedisDecompressAlgor else decompressedState.value = { status: "error", reason: result.reason }; } -/** Bytes of whichever decompressed view is active (string value or member), or null when there is nothing to decode. */ +/** Bytes of whichever surface has a byte-level codec active (string value or member), or null when there is nothing to decode. */ function currentDecompressTargetBytes(): Uint8Array | null { - if (stringValueView.value === "decompressed") { + if (isDecompressCodec(stringValueCodec.value) || stringValueCodec.value === "base64") { const blob = stringBlob.value; return blob ? decodeRedisBlob(blob) : null; } - if (memberValueView.value === "decompressed") { + if (isDecompressCodec(memberValueCodec.value) || memberValueCodec.value === "base64") { const raw = selectedMemberRaw.value; if (raw == null) return null; // Plain-string members (not blobs) still attempt decompression so the user @@ -244,13 +255,6 @@ const decompressedFailureMessage = computed(() => { return t("redis.decompressedFailed"); }); -/** Format label shows the algorithm that actually succeeded, e.g. "Decompressed (zlib)". */ -const decompressedLabel = computed(() => { - const state = decompressedState.value; - const base = t("redis.decompressedView"); - return state.status === "success" ? `${base} (${state.algorithm})` : base; -}); - // Auto-refresh keeps the displayed TTL moving locally and periodically reloads // the complete key detail. The full reload updates changed values as well as // the authoritative TTL without rebuilding the parent key tree. @@ -434,40 +438,129 @@ const stringBlob = computed(() => { const stringValueDetail = computed(() => (stringBlob.value ? formatRedisMemberDetail(stringBlob.value, { allowJsonText: true }) : null)); const selectedMemberDetail = computed(() => formatRedisMemberDetail(selectedMemberRaw.value, { allowJsonText: true })); -// The Decompressed view depends on the value/format refs above, so these -// watchers and computeds live here rather than next to the state declarations. -watch([stringValueView, stringBlob], ([view]) => { - if (view !== "decompressed") return; - refreshDecompressedView(); +// Decompression depends on the value/codec refs above, so these watchers and +// computeds live here rather than next to the state declarations. +watch([stringValueCodec, stringBlob], ([codec]) => { + if (!isDecompressCodec(codec)) return; + refreshDecompressedView(codec); }); -watch([memberValueView, selectedMemberRaw], ([view]) => { - if (view !== "decompressed") return; - refreshDecompressedView(); +watch([memberValueCodec, selectedMemberRaw], ([codec]) => { + if (!isDecompressCodec(codec)) return; + refreshDecompressedView(codec); }); const stringGzipBadge = computed(() => (stringBlob.value ? isGzipMagic(decodeRedisBlob(stringBlob.value)) : false)); const memberGzipBadge = computed(() => (isRedisBlob(selectedMemberRaw.value) ? isGzipMagic(decodeRedisBlob(selectedMemberRaw.value)) : false)); -/** Raw fallback shown while Decompressed fails: keep the original content visible, never an error string in its place. */ +interface RedisDecodedDetail { + formattedText: string; + value: unknown; +} + +/** Structured codec results are pre-computed by formatRedisMemberDetail; just route by codec. */ +const activeStructuredStringDetail = computed(() => { + const detail = stringValueDetail.value; + const codec = stringValueCodec.value; + if (!detail || !isStructuredCodec(codec)) return null; + if (codec === "pickle" && stringBlob.value) return detail.pickle ?? decodePickle(decodeRedisBlob(stringBlob.value)); + if (codec === "msgpack" && detail.msgpack) return detail.msgpack; + if (codec === "phpserialize" && detail.phpSerialized) return detail.phpSerialized; + if (codec === "javaserialize" && detail.javaSerialized) return detail.javaSerialized; + // Protobuf is schema-less and false-positives on arbitrary bytes, so it is + // decoded lazily here instead of being pre-computed by formatRedisMemberDetail. + if (codec === "protobuf" && stringBlob.value) return decodeProtobuf(decodeRedisBlob(stringBlob.value)); + return null; +}); + +const activeStructuredMemberDetail = computed(() => { + const detail = selectedMemberDetail.value; + const codec = memberValueCodec.value; + if (!isStructuredCodec(codec)) return null; + if (codec === "pickle" && isRedisBlob(selectedMemberRaw.value)) return detail.pickle ?? decodePickle(decodeRedisBlob(selectedMemberRaw.value)); + if (codec === "msgpack" && detail.msgpack) return detail.msgpack; + if (codec === "phpserialize" && detail.phpSerialized) return detail.phpSerialized; + if (codec === "javaserialize" && detail.javaSerialized) return detail.javaSerialized; + if (codec === "protobuf" && isRedisBlob(selectedMemberRaw.value)) return decodeProtobuf(decodeRedisBlob(selectedMemberRaw.value)); + return null; +}); + +const stringBase64Detail = computed(() => { + if (stringValueCodec.value !== "base64") return null; + const blob = stringBlob.value; + if (!blob) return null; + const text = decodeBase64RedisValue(decodeRedisBlob(blob)); + return text == null ? null : { text, json: parseRedisJsonDetail(text) }; +}); + +const memberBase64Detail = computed(() => { + if (memberValueCodec.value !== "base64") return null; + const raw = selectedMemberRaw.value; + if (raw == null) return null; + const bytes = isRedisBlob(raw) ? decodeRedisBlob(raw) : new TextEncoder().encode(typeof raw === "string" ? raw : formatRedisMemberDetail(raw).rawText); + const text = decodeBase64RedisValue(bytes); + return text == null ? null : { text, json: parseRedisJsonDetail(text) }; +}); + +/** A codec the payload does not match keeps the original content visible instead of an empty pane. */ +const stringCodecMismatch = computed(() => (isStructuredCodec(stringValueCodec.value) || stringValueCodec.value === "base64") && !activeStructuredStringDetail.value && !stringBase64Detail.value && stringValueDetail.value != null); +const memberCodecMismatch = computed(() => (isStructuredCodec(memberValueCodec.value) || memberValueCodec.value === "base64") && !activeStructuredMemberDetail.value && !memberBase64Detail.value); + +/** Text a codec renders for text-like views; JSON-derived views derive from the decoded value. */ +function structuredViewText(value: unknown, formattedText: string, view: RedisValueFormat): string { + if (view === "yaml") return jsonToYamlText(value); + if (view === "xml") return jsonToXmlText(value); + if (view === "unicodejson") return decodeJsonUnicodeEscapes(formattedText); + return formattedText; +} + +const stringDecodedText = computed(() => { + const structured = activeStructuredStringDetail.value; + if (structured) return structuredViewText(structured.value, structured.formattedText, stringValueView.value); + if (stringValueCodec.value === "base64") { + const decoded = stringBase64Detail.value; + if (!decoded) return null; + return decoded.json ? structuredViewText(decoded.json.value, decoded.json.formattedText, stringValueView.value) : decoded.text; + } + if (isDecompressCodec(stringValueCodec.value)) { + const state = decompressedState.value; + if (state.status !== "success") return null; + const json = decompressedJsonDetail.value; + return json ? structuredViewText(json.value, json.formattedText, stringValueView.value) : state.text; + } + return null; +}); + +const memberDecodedText = computed(() => { + const structured = activeStructuredMemberDetail.value; + if (structured) return structuredViewText(structured.value, structured.formattedText, memberValueView.value); + if (memberValueCodec.value === "base64") { + const decoded = memberBase64Detail.value; + if (!decoded) return null; + return decoded.json ? structuredViewText(decoded.json.value, decoded.json.formattedText, memberValueView.value) : decoded.text; + } + if (isDecompressCodec(memberValueCodec.value)) { + const state = decompressedState.value; + if (state.status !== "success") return null; + const json = decompressedJsonDetail.value; + return json ? structuredViewText(json.value, json.formattedText, memberValueView.value) : state.text; + } + return null; +}); + +/** Raw fallback shown while a decompress codec fails: keep the original content visible, never an error string in its place. */ const decompressedRawFallbackText = computed(() => { - if (stringValueView.value === "decompressed" && stringValueDetail.value) { + if (isDecompressCodec(stringValueCodec.value) && stringValueDetail.value) { return detailTextForFormat(stringValueDetail.value, stringValueDetail.value.defaultFormat); } - if (memberValueView.value === "decompressed" && selectedMemberDetail.value) { + if (isDecompressCodec(memberValueCodec.value) && selectedMemberDetail.value) { return detailTextForFormat(selectedMemberDetail.value, selectedMemberDetail.value.defaultFormat); } return ""; }); -/** Copy targets the decompressed text while the Decompressed view is showing it. */ -const memberCopyText = computed(() => { - if (memberValueView.value === "decompressed") { - const state = decompressedState.value; - if (state.status === "success") return state.text; - } - return detailTextForFormat(selectedMemberDetail.value, memberValueView.value); -}); +/** Copy targets the decoded text while a codec is decoding the member. */ +const memberCopyText = computed(() => memberDecodedText.value ?? detailTextForFormat(selectedMemberDetail.value, memberValueView.value)); const redisJsonAppearance = computed(() => (isDark.value ? "dark" : "light")); const isBinaryStringValue = computed(() => Boolean(stringValueDetail.value?.binary)); @@ -624,18 +717,11 @@ const valueSearchSupported = computed(() => showMemberDetail.value || isStringLi const contentSearchText = computed(() => { if (showMemberDetail.value) { if (isEditingMember.value || isEditingHashJson.value) return memberEditValue.value; - if (memberValueView.value === "decompressed") { - const state = decompressedState.value; - return state.status === "success" ? state.text : ""; - } - return detailTextForFormat(selectedMemberDetail.value, memberValueView.value); + return memberDecodedText.value ?? detailTextForFormat(selectedMemberDetail.value, memberValueView.value); } if (redisKind.value === "json") return editValue.value; if (!isStringLikeKind.value || !stringValueDetail.value) return ""; - if (stringValueView.value === "decompressed") { - const state = decompressedState.value; - return state.status === "success" ? state.text : ""; - } + if (stringValueCodec.value !== "none") return stringDecodedText.value ?? ""; if (stringValueView.value === "json" && stringValueDetail.value.json) return editValue.value; if (stringValueView.value === "utf8" && canEditCurrentStringFormat.value) return editValue.value; return detailTextForFormat(stringValueDetail.value, stringValueView.value); @@ -1094,12 +1180,29 @@ function readPreferredRedisValueFormat(): RedisValueFormat { try { const stored = localStorage.getItem(REDIS_VALUE_FORMAT_STORAGE_KEY); if (stored === "raw") return "utf8"; - return stored === "utf8" || stored === "ascii" || stored === "binary" || stored === "json" || stored === "javaserialize" || stored === "hex" || stored === "base64" ? stored : "utf8"; + return stored === "utf8" || stored === "ascii" || stored === "binary" || stored === "json" || stored === "hex" || stored === "base64" ? stored : "utf8"; } catch { return "utf8"; } } +function readPreferredRedisValueCodec(): RedisValueCodec { + try { + const stored = localStorage.getItem(REDIS_VALUE_CODEC_STORAGE_KEY); + return isRedisValueCodec(stored) ? stored : "none"; + } catch { + return "none"; + } +} + +function rememberRedisValueCodec(codec: RedisValueCodec) { + try { + localStorage.setItem(REDIS_VALUE_CODEC_STORAGE_KEY, codec); + } catch { + // Ignore storage failures; the codec still applies for the current session. + } +} + function formatJsonText(raw: string, decodeUnicode: boolean): string | null { try { // Keep Redis JSON baselines source-preserving (duplicate keys, number text), @@ -1188,11 +1291,15 @@ function setStringValueFormat(format: RedisValueFormat) { // Keep a dirty JSON draft marked as json so save still compact-writes after a tab switch. if (!(stringDraftFormat.value === "json" && isStringDraftDirty("json"))) stringDraftFormat.value = "utf8"; } - // Decompressed is a per-value view, never a persisted preference. - if (format !== "decompressed") rememberRedisValueFormat(format); + rememberRedisValueFormat(format); } } +function setStringValueCodec(codec: RedisValueCodec) { + stringValueCodec.value = codec; + rememberRedisValueCodec(codec); +} + function setMemberValueFormat(format: RedisValueFormat) { if (memberValueView.value === "json" && format !== "json" && selectedMemberDetail.value.json && memberEditValue.value === memberJsonDraftBaseline.value) { memberEditValue.value = selectedMemberDetail.value.rawText; @@ -1211,11 +1318,15 @@ function setMemberValueFormat(format: RedisValueFormat) { memberDraftFormat.value = "utf8"; } } - // Decompressed is a per-value view, never a persisted preference. - if (format !== "decompressed") rememberRedisValueFormat(format); + rememberRedisValueFormat(format); } } +function setMemberValueCodec(codec: RedisValueCodec) { + memberValueCodec.value = codec; + rememberRedisValueCodec(codec); +} + function redisFormatLabel(format: RedisValueFormat, rawLabel?: string): string { switch (format) { case "utf8": @@ -1226,21 +1337,65 @@ function redisFormatLabel(format: RedisValueFormat, rawLabel?: string): string { return "Binary"; case "json": return t("redis.jsonView"); - case "javaserialize": - return "Java Serialized"; + case "unicodejson": + return t("redis.unicodeJsonView"); + case "yaml": + return "YAML"; + case "xml": + return "XML"; case "hex": return t("grid.hexViewerHex"); case "base64": return "Base64"; - case "decompressed": - return decompressedLabel.value; default: return rawLabel || t("redis.rawContent"); } } +function redisCodecLabel(codec: RedisValueCodec): string { + switch (codec) { + case "gzip": + return "GZip"; + case "zlib": + return "Zlib"; + case "deflate": + return "Deflate"; + case "base64": + return t("redis.codecBase64"); + case "msgpack": + return "Msgpack"; + case "pickle": + return "Pickle"; + case "phpserialize": + return "PHP Serialize"; + case "protobuf": + return "Protobuf"; + case "javaserialize": + return "Java Serialized"; + default: + return t("redis.codecNone"); + } +} + function isTextRedisFormat(format: RedisValueFormat): boolean { - return format === "utf8" || format === "ascii" || format === "binary" || format === "json" || format === "decompressed"; + return format === "utf8" || format === "ascii" || format === "binary" || format === "json" || format === "unicodejson" || format === "yaml" || format === "xml"; +} + +/** JSON-derived views are available when the raw value parses as JSON or the active codec produces a JSON-able value. */ +function canRenderStringValueView(format: RedisValueFormat): boolean { + if (!isJsonDerivedView(format)) return true; + if (stringValueDetail.value?.json) return true; + if (activeStructuredStringDetail.value) return true; + if (stringValueCodec.value === "base64") return stringBase64Detail.value?.json != null; + return isDecompressCodec(stringValueCodec.value) && decompressedState.value.status === "success" && decompressedJsonDetail.value != null; +} + +function canRenderMemberValueView(format: RedisValueFormat): boolean { + if (!isJsonDerivedView(format)) return true; + if (selectedMemberDetail.value.json) return true; + if (activeStructuredMemberDetail.value) return true; + if (memberValueCodec.value === "base64") return memberBase64Detail.value?.json != null; + return isDecompressCodec(memberValueCodec.value) && decompressedState.value.status === "success" && decompressedJsonDetail.value != null; } function highlightRedisJson(json: string): string { @@ -1261,8 +1416,12 @@ function detailTextForFormat(detail: ReturnType, return detail.binaryText; case "json": return detail.json?.formattedText ?? detail.rawText; - case "javaserialize": - return detail.javaSerialized?.formattedText ?? detail.rawText; + case "unicodejson": + return detail.json ? decodeJsonUnicodeEscapes(detail.json.formattedText) : detail.rawText; + case "yaml": + return detail.json ? jsonToYamlText(detail.json.value) : detail.rawText; + case "xml": + return detail.json ? jsonToXmlText(detail.json.value) : detail.rawText; case "hex": return detail.hexRows.map((row) => row.hex).join("\n"); case "base64": @@ -1527,13 +1686,10 @@ async function renameKey() { async function copyValue() { if (!data.value) return; - // Copy the decompressed text while the Decompressed view is showing it. - if (stringValueView.value === "decompressed") { - const state = decompressedState.value; - if (state.status === "success") { - await copyText(state.text); - return; - } + // Copy the decoded text while a codec is decoding the string value. + if (stringValueCodec.value !== "none" && stringDecodedText.value != null) { + await copyText(stringDecodedText.value); + return; } const value = data.value.data.kind === "stream" ? { ...data.value, data: { ...data.value.data, entries: streamEntries.value } } : data.value; const text = redisValueCopyText(value, collectionItems.value); @@ -2584,6 +2740,22 @@ defineExpose({ focusSearch });
+ {{ t("redis.codecRowLabel") }} +
+ +
+ + + +
+
+ {{ t("redis.viewRowLabel") }}
- -
+
-
-
- +
+ +
{{ stringDecodedText }}
+
+
+ +
{{ stringDecodedText }}
+
+
+
{{ detailTextForFormat(stringValueDetail, stringValueDetail.defaultFormat) }}
+
{{ t("redis.codecMismatch") }}
@@ -2638,19 +2810,19 @@ defineExpose({ focusSearch });
         
{{ stringValueDetail.base64Text }}
-
+
{{ t("redis.decompressedLoading") }}
-
+
-
{{ decompressedState.text }}
+
{{ stringDecodedText }}