fix(redis): support legacy pickle and valid XML tags

This commit is contained in:
Zhuxing authored and GitHub committed 2026-08-21 12:21:37 +08:00
1 parent b09fd35cd8
commit 4bf7551b82
27 files changed
+1778 -133

No files matched your search

@@ -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<RedisValueFormat>(readPreferredRedisValueFormat());
const memberValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
const stringValueCodec = ref<RedisValueCodec>(readPreferredRedisValueCodec());
const memberValueCodec = ref<RedisValueCodec>(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<RedisBlob | null>(() => {
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<RedisDecodedDetail | null>(() => {
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<RedisDecodedDetail | null>(() => {
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<string | null>(() => {
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<string | null>(() => {
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<typeof formatRedisMemberDetail>,
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 });
<!-- String -->
<div v-if="isStringLikeKind && stringValueDetail" class="flex-1 flex flex-col overflow-hidden">
<div class="flex h-9 items-center gap-2 border-b px-4 text-xs shrink-0">
<span class="shrink-0 text-muted-foreground">{{ t("redis.codecRowLabel") }}</span>
<div class="flex max-w-full overflow-x-auto rounded-md border bg-muted/20 p-0.5">
<Button v-for="codec in REDIS_VALUE_CODEC_ORDER" :key="codec" variant="ghost" size="sm" class="h-6 shrink-0 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': stringValueCodec === codec }" @click="setStringValueCodec(codec)">
{{ redisCodecLabel(codec) }}
</Button>
</div>
<FileArchive v-if="stringGzipBadge && stringValueCodec === 'none'" class="h-3.5 w-3.5 shrink-0 text-muted-foreground" :title="t('redis.gzipBadgeTitle')" :aria-label="t('redis.gzipBadgeTitle')" />
<span class="flex-1" />
<label v-if="isTextRedisFormat(stringValueView) || activeStructuredStringDetail || isDecompressCodec(stringValueCodec)" class="flex items-center gap-1.5 text-muted-foreground">
<WrapText class="h-3.5 w-3.5" />
{{ t("redis.wordWrap") }}
<Switch size="sm" :model-value="redisJsonWordWrap" @update:model-value="setRedisJsonWordWrap(Boolean($event))" />
</label>
</div>
<div class="flex h-9 items-center gap-2 border-b px-4 text-xs shrink-0">
<span class="shrink-0 text-muted-foreground">{{ t("redis.viewRowLabel") }}</span>
<div class="flex max-w-full overflow-x-auto rounded-md border bg-muted/20 p-0.5">
<Button
v-for="format in REDIS_VALUE_FORMAT_DISPLAY_ORDER"
@@ -2592,29 +2764,20 @@ defineExpose({ focusSearch });
size="sm"
class="h-6 shrink-0 rounded-[5px] px-2 text-xs"
:class="{ 'bg-background shadow-sm': stringValueView === format }"
:disabled="!canRenderRedisValueFormat(stringValueDetail, format)"
:disabled="!canRenderStringValueView(format)"
@click="setStringValueFormat(format)"
>
{{ redisFormatLabel(format, stringValueDetail.rawLabel) }}
</Button>
</div>
<Button v-if="stringGzipBadge" variant="outline" size="sm" class="h-6 shrink-0 rounded-[5px] px-2 text-xs text-muted-foreground" :title="t('redis.gzipBadgeTitle')" :aria-label="t('redis.gzipBadgeTitle')" @click="setStringValueFormat('decompressed')">
<FileArchive class="h-3.5 w-3.5 mr-1" />
Gzip
</Button>
<span class="flex-1" />
<div v-if="stringValueView === 'json' && stringValueDetail.json" class="flex shrink-0 overflow-hidden rounded-md border bg-muted/20 p-0.5">
<div v-if="stringValueView === 'json' && stringValueDetail.json && stringValueCodec === 'none'" class="flex shrink-0 overflow-hidden rounded-md border bg-muted/20 p-0.5">
<Button variant="ghost" size="sm" class="h-6 shrink-0 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': !redisJsonDecoded }" @click="setRedisJsonUnicodeMode('raw')">{{ t("redis.jsonViewRaw") }}</Button>
<Button variant="ghost" size="sm" class="h-6 shrink-0 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': redisJsonDecoded }" @click="setRedisJsonUnicodeMode('decoded')">{{ t("redis.jsonViewDecoded") }}</Button>
</div>
<label v-if="isTextRedisFormat(stringValueView)" class="flex items-center gap-1.5 text-muted-foreground">
<WrapText class="h-3.5 w-3.5" />
{{ t("redis.wordWrap") }}
<Switch size="sm" :model-value="redisJsonWordWrap" @update:model-value="setRedisJsonWordWrap(Boolean($event))" />
</label>
</div>
<RedisJsonEditor
v-if="stringValueView === 'json' && stringValueDetail.json"
v-if="stringValueView === 'json' && stringValueDetail.json && stringValueCodec === 'none'"
ref="stringJsonEditorRef"
v-model="editValue"
class="min-h-0 flex-1"
@@ -2624,8 +2787,17 @@ defineExpose({ focusSearch });
:enable-builtin-find="false"
@save="saveString"
/>
<div v-else-if="stringValueView === 'javaserialize' && stringValueDetail.javaSerialized" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-4 text-sm leading-6">
<JsonTree :value="stringValueDetail.javaSerialized.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
<div v-else-if="activeStructuredStringDetail && stringValueView !== 'hex' && stringValueView !== 'base64'" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-4 text-sm leading-6">
<JsonTree v-if="stringValueView === 'json'" :value="activeStructuredStringDetail.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
<pre v-else class="w-full min-w-0 max-w-full" :class="redisJsonWordWrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'">{{ stringDecodedText }}</pre>
</div>
<div v-else-if="stringValueCodec === 'base64' && stringBase64Detail" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-4 text-sm leading-6">
<JsonTree v-if="stringValueView === 'json' && stringBase64Detail.json" :value="stringBase64Detail.json.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
<pre v-else class="w-full min-w-0 max-w-full" :class="redisJsonWordWrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'">{{ stringDecodedText }}</pre>
</div>
<div v-else-if="stringCodecMismatch" class="min-h-0 flex-1 flex flex-col overflow-hidden">
<pre class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-4 text-sm leading-6">{{ detailTextForFormat(stringValueDetail, stringValueDetail.defaultFormat) }}</pre>
<div class="flex shrink-0 flex-wrap items-center gap-2 border-t px-4 py-2 text-xs text-muted-foreground">{{ t("redis.codecMismatch") }}</div>
</div>
<div v-else-if="stringValueView === 'hex'" class="min-h-0 flex-1 overflow-auto bg-background p-4 text-xs leading-5">
<div class="mb-3 flex items-center justify-between text-muted-foreground">
@@ -2638,19 +2810,19 @@ defineExpose({ focusSearch });
</div>
<pre v-else-if="stringValueView === 'base64' && canHighlightStringSurface" class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-4 text-sm leading-6 whitespace-pre-wrap break-all" v-html="contentSearchHighlightedHtml" />
<pre v-else-if="stringValueView === 'base64'" class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-4 text-sm leading-6 whitespace-pre-wrap break-all">{{ stringValueDetail.base64Text }}</pre>
<div v-else-if="stringValueView === 'decompressed'" class="min-h-0 flex-1 flex flex-col overflow-hidden">
<div v-else-if="isDecompressCodec(stringValueCodec)" class="min-h-0 flex-1 flex flex-col overflow-hidden">
<div v-if="decompressedState.status === 'loading'" class="flex min-h-0 flex-1 items-center justify-center gap-2 text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("redis.decompressedLoading") }}
</div>
<div v-else-if="decompressedState.status === 'success'" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background">
<div v-if="decompressedJsonDetail" class="p-4">
<div v-if="stringValueView === 'json' && decompressedJsonDetail" class="p-4">
<JsonTree :value="decompressedJsonDetail.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
</div>
<pre v-else class="w-full min-w-0 max-w-full p-4 text-sm leading-6" :class="detailTextClass('decompressed')">{{ decompressedState.text }}</pre>
<pre v-else class="w-full min-w-0 max-w-full p-4 text-sm leading-6" :class="detailTextClass('utf8')">{{ stringDecodedText }}</pre>
</div>
<template v-else>
<pre class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-4 text-sm leading-6" :class="detailTextClass('decompressed')">{{ decompressedRawFallbackText }}</pre>
<pre class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-4 text-sm leading-6" :class="detailTextClass('utf8')">{{ decompressedRawFallbackText }}</pre>
<div v-if="decompressedFailureMessage" class="flex shrink-0 flex-wrap items-center gap-2 border-t px-4 py-2 text-xs text-muted-foreground">
<span>{{ decompressedFailureMessage }}</span>
<Button
@@ -2668,7 +2840,7 @@ defineExpose({ focusSearch });
</template>
</div>
<textarea
v-else-if="stringValueView === 'utf8' && canEditCurrentStringFormat"
v-else-if="stringValueView === 'utf8' && canEditCurrentStringFormat && stringValueCodec === 'none'"
ref="stringTextareaRef"
v-model="editValue"
class="dbx-editor-font-family flex-1 resize-none bg-background p-4 text-sm outline-none"
@@ -3310,7 +3482,7 @@ defineExpose({ focusSearch });
<DialogTitle class="flex items-center gap-2">
<span class="truncate">{{ selectedMemberTitle ? formatValue(selectedMemberTitle) : t("redis.memberDetail") }}</span>
<Badge variant="outline" class="shrink-0 text-xs">{{ redisFormatLabel(memberValueView, selectedMemberDetail.rawLabel) }}</Badge>
<Badge v-if="memberGzipBadge" variant="outline" class="shrink-0 cursor-pointer text-xs text-muted-foreground" :title="t('redis.gzipBadgeTitle')" :aria-label="t('redis.gzipBadgeTitle')" @click="setMemberValueFormat('decompressed')">
<Badge v-if="memberGzipBadge && memberValueCodec === 'none'" variant="outline" class="shrink-0 text-xs text-muted-foreground" :title="t('redis.gzipBadgeTitle')" :aria-label="t('redis.gzipBadgeTitle')">
<FileArchive class="h-3 w-3 mr-1" />
Gzip
</Badge>
@@ -3321,6 +3493,21 @@ defineExpose({ focusSearch });
</template>
<template v-else>
<div class="flex h-9 items-center gap-2 border-b px-5 text-xs">
<span class="shrink-0 text-muted-foreground">{{ t("redis.codecRowLabel") }}</span>
<div class="flex max-w-full overflow-x-auto rounded-md border bg-muted/20 p-0.5">
<Button v-for="codec in REDIS_VALUE_CODEC_ORDER" :key="codec" variant="ghost" size="sm" class="h-6 shrink-0 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': memberValueCodec === codec }" @click="setMemberValueCodec(codec)">
{{ redisCodecLabel(codec) }}
</Button>
</div>
<span class="flex-1" />
<label v-if="isTextRedisFormat(memberValueView) || activeStructuredMemberDetail || isDecompressCodec(memberValueCodec)" class="flex items-center gap-1.5 text-muted-foreground">
<WrapText class="h-3.5 w-3.5" />
{{ t("redis.wordWrap") }}
<Switch size="sm" :model-value="redisJsonWordWrap" @update:model-value="setRedisJsonWordWrap(Boolean($event))" />
</label>
</div>
<div class="flex h-9 items-center gap-2 border-b px-5 text-xs">
<span class="shrink-0 text-muted-foreground">{{ t("redis.viewRowLabel") }}</span>
<div class="flex max-w-full overflow-x-auto rounded-md border bg-muted/20 p-0.5">
<Button
v-for="format in REDIS_VALUE_FORMAT_DISPLAY_ORDER"
@@ -3329,29 +3516,33 @@ defineExpose({ focusSearch });
size="sm"
class="h-6 shrink-0 rounded-[5px] px-2 text-xs"
:class="{ 'bg-background shadow-sm': memberValueView === format }"
:disabled="!canRenderRedisValueFormat(selectedMemberDetail, format)"
:disabled="!canRenderMemberValueView(format)"
@click="setMemberValueFormat(format)"
>
{{ redisFormatLabel(format, selectedMemberDetail.rawLabel) }}
</Button>
</div>
<span class="flex-1" />
<div v-if="memberValueView === 'json' && selectedMemberDetail.json" class="flex shrink-0 overflow-hidden rounded-md border bg-muted/20 p-0.5">
<div v-if="memberValueView === 'json' && selectedMemberDetail.json && memberValueCodec === 'none'" class="flex shrink-0 overflow-hidden rounded-md border bg-muted/20 p-0.5">
<Button variant="ghost" size="sm" class="h-6 shrink-0 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': !redisJsonDecoded }" @click="setRedisJsonUnicodeMode('raw')">{{ t("redis.jsonViewRaw") }}</Button>
<Button variant="ghost" size="sm" class="h-6 shrink-0 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': redisJsonDecoded }" @click="setRedisJsonUnicodeMode('decoded')">{{ t("redis.jsonViewDecoded") }}</Button>
</div>
<label v-if="isTextRedisFormat(memberValueView)" class="flex items-center gap-1.5 text-muted-foreground">
<WrapText class="h-3.5 w-3.5" />
{{ t("redis.wordWrap") }}
<Switch size="sm" :model-value="redisJsonWordWrap" @update:model-value="setRedisJsonWordWrap(Boolean($event))" />
</label>
</div>
<RedisJsonEditor v-if="isEditingHashJson" ref="memberJsonEditorRef" v-model="memberEditValue" class="min-h-0 flex-1" :save-disabled="savingMember || !memberValueChanged" :read-only="savingMember" :word-wrap="redisJsonWordWrap" :enable-builtin-find="false" @save="saveMemberEdit" />
<div v-else-if="memberValueView === 'json' && selectedMemberDetail.json" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-5 text-[13px] leading-6">
<div v-else-if="memberValueView === 'json' && selectedMemberDetail.json && memberValueCodec === 'none'" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-5 text-[13px] leading-6">
<JsonTree :value="selectedMemberDetail.json.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
</div>
<div v-else-if="memberValueView === 'javaserialize' && selectedMemberDetail.javaSerialized" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-5 text-[13px] leading-6">
<JsonTree :value="selectedMemberDetail.javaSerialized.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
<div v-else-if="activeStructuredMemberDetail && memberValueView !== 'hex' && memberValueView !== 'base64'" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-5 text-[13px] leading-6">
<JsonTree v-if="memberValueView === 'json'" :value="activeStructuredMemberDetail.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
<pre v-else class="w-full min-w-0 max-w-full" :class="redisJsonWordWrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'">{{ memberDecodedText }}</pre>
</div>
<div v-else-if="memberValueCodec === 'base64' && memberBase64Detail" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-5 text-[13px] leading-6">
<JsonTree v-if="memberValueView === 'json' && memberBase64Detail.json" :value="memberBase64Detail.json.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
<pre v-else class="w-full min-w-0 max-w-full" :class="redisJsonWordWrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'">{{ memberDecodedText }}</pre>
</div>
<div v-else-if="memberCodecMismatch" class="min-h-0 flex-1 flex flex-col overflow-hidden">
<pre class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-5 text-[13px] leading-6">{{ detailTextForFormat(selectedMemberDetail, selectedMemberDetail.defaultFormat) }}</pre>
<div class="flex shrink-0 flex-wrap items-center gap-2 border-t px-5 py-2 text-xs text-muted-foreground">{{ t("redis.codecMismatch") }}</div>
</div>
<div v-else-if="memberValueView === 'hex'" class="min-h-0 flex-1 overflow-auto bg-background p-5 text-xs leading-5">
<div class="mb-3 flex items-center justify-between text-muted-foreground">
@@ -3364,19 +3555,19 @@ defineExpose({ focusSearch });
</div>
<pre v-else-if="memberValueView === 'base64' && canHighlightMemberSurface" class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-5 text-[13px] leading-6 whitespace-pre-wrap break-all" v-html="contentSearchHighlightedHtml" />
<pre v-else-if="memberValueView === 'base64'" class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-5 text-[13px] leading-6 whitespace-pre-wrap break-all">{{ selectedMemberDetail.base64Text }}</pre>
<div v-else-if="memberValueView === 'decompressed'" class="min-h-0 flex-1 flex flex-col overflow-hidden">
<div v-else-if="isDecompressCodec(memberValueCodec)" class="min-h-0 flex-1 flex flex-col overflow-hidden">
<div v-if="decompressedState.status === 'loading'" class="flex min-h-0 flex-1 items-center justify-center gap-2 text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("redis.decompressedLoading") }}
</div>
<div v-else-if="decompressedState.status === 'success'" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background">
<div v-if="decompressedJsonDetail" class="p-5">
<div v-if="memberValueView === 'json' && decompressedJsonDetail" class="p-5">
<JsonTree :value="decompressedJsonDetail.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
</div>
<pre v-else class="w-full min-w-0 max-w-full p-5 text-[13px] leading-6" :class="detailTextClass('decompressed')">{{ decompressedState.text }}</pre>
<pre v-else class="w-full min-w-0 max-w-full p-5 text-[13px] leading-6" :class="detailTextClass('utf8')">{{ memberDecodedText }}</pre>
</div>
<template v-else>
<pre class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-5 text-[13px] leading-6" :class="detailTextClass('decompressed')">{{ decompressedRawFallbackText }}</pre>
<pre class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-5 text-[13px] leading-6" :class="detailTextClass('utf8')">{{ decompressedRawFallbackText }}</pre>
<div v-if="decompressedFailureMessage" class="flex shrink-0 flex-wrap items-center gap-2 border-t px-5 py-2 text-xs text-muted-foreground">
<span>{{ decompressedFailureMessage }}</span>
<Button
@@ -3393,7 +3584,7 @@ defineExpose({ focusSearch });
</div>
</template>
</div>
<div v-else-if="memberValueView === 'utf8' && canEditCurrentMemberFormat" data-redis-member-utf8-viewer class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background text-[13px] leading-6 cursor-text" @dblclick.self.prevent="startEditMember">
<div v-else-if="memberValueView === 'utf8' && canEditCurrentMemberFormat && memberValueCodec === 'none'" data-redis-member-utf8-viewer class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background text-[13px] leading-6 cursor-text" @dblclick.self.prevent="startEditMember">
<pre v-if="canHighlightMemberSurface" data-redis-member-utf8-text class="inline-block min-w-0 p-5 align-top select-text" :class="[detailTextClass('utf8'), redisJsonWordWrap ? 'max-w-full' : 'min-w-max']" v-html="contentSearchHighlightedHtml" />
<pre v-else data-redis-member-utf8-text class="inline-block min-w-0 p-5 align-top select-text" :class="[detailTextClass('utf8'), redisJsonWordWrap ? 'max-w-full' : 'min-w-max']">{{ detailTextForFormat(selectedMemberDetail, "utf8") }}</pre>
</div>
+6
View File
@@ -4556,6 +4556,12 @@ export default {
jsonViewDecoded: "Decoded",
rawContent: "Raw content",
wordWrap: "Word wrap",
codecNone: "None",
codecRowLabel: "Decode",
viewRowLabel: "View",
codecBase64: "Base64 Decode",
unicodeJsonView: "Unicode JSON",
codecMismatch: "Unable to decode with the selected codec; showing the original content",
decompressedView: "Decompressed",
decompressedLoading: "Decompressing…",
decompressedFailed: "Unable to decompress with the selected format",
+6
View File
@@ -4377,6 +4377,12 @@ export default withEnglishFallback({
jsonViewDecoded: "Decodificado",
rawContent: "Contenido original",
wordWrap: "Ajuste de línea",
codecNone: "Ninguno",
codecRowLabel: "Descodificar",
viewRowLabel: "Vista",
codecBase64: "Descodificación Base64",
codecMismatch: "No se pudo decodificar con el códec seleccionado; se muestra el contenido original",
unicodeJsonView: "JSON Unicode",
decompressedView: "Descomprimido",
decompressedLoading: "Descomprimiendo…",
decompressedFailed: "No se puede descomprimir con el formato seleccionado",
+6
View File
@@ -4377,6 +4377,12 @@ export default withEnglishFallback({
jsonViewDecoded: "Decodificato",
rawContent: "Contenuto grezzo",
wordWrap: "A capo automatico",
codecNone: "Nessuno",
codecRowLabel: "Decodifica",
viewRowLabel: "Vista",
codecBase64: "Decodifica Base64",
codecMismatch: "Impossibile decodificare con il codec selezionato; viene mostrato il contenuto originale",
unicodeJsonView: "JSON Unicode",
decompressedView: "Decompresso",
decompressedLoading: "Decompressione in corso…",
decompressedFailed: "Impossibile decomprimere con il formato selezionato",
+6
View File
@@ -4407,6 +4407,12 @@ export default withEnglishFallback({
jsonViewDecoded: "デコード",
rawContent: "生コンテンツ",
wordWrap: "折り返し",
codecNone: "なし",
codecRowLabel: "デコード",
viewRowLabel: "表示",
codecBase64: "Base64 デコード",
codecMismatch: "選択したデコード方式では復元できません。元の内容を表示しています",
unicodeJsonView: "Unicode JSON",
decompressedView: "解凍ビュー",
decompressedLoading: "解凍中…",
decompressedFailed: "選択した形式で解凍できません",
+6
View File
@@ -4026,6 +4026,12 @@ export default withEnglishFallback({
jsonViewDecoded: "디코딩",
rawContent: "원시 내용",
wordWrap: "자동 줄바꿈",
codecNone: "없음",
codecRowLabel: "디코딩",
viewRowLabel: "보기",
codecBase64: "Base64 디코딩",
codecMismatch: "선택한 디코딩 방식으로 복원할 수 없어 원본 내용을 표시합니다",
unicodeJsonView: "Unicode JSON",
decompressedView: "압축 해제 보기",
decompressedLoading: "압축 해제 중…",
decompressedFailed: "선택한 형식으로 압축을 해제할 수 없습니다",
+6
View File
@@ -4379,6 +4379,12 @@ export default withEnglishFallback({
jsonViewDecoded: "Decodificado",
rawContent: "Conteúdo bruto",
wordWrap: "Quebra de linha",
codecNone: "Nenhum",
codecRowLabel: "Decodificação",
viewRowLabel: "Visualização",
codecBase64: "Decodificação Base64",
codecMismatch: "Não foi possível decodificar com o codec selecionado; exibindo o conteúdo original",
unicodeJsonView: "JSON Unicode",
decompressedView: "Descomprimido",
decompressedLoading: "Descomprimindo…",
decompressedFailed: "Não foi possível descomprimir com o formato selecionado",
+6
View File
@@ -4543,6 +4543,12 @@ export default withEnglishFallback({
jsonViewDecoded: "解码",
rawContent: "原始内容",
wordWrap: "自动换行",
codecNone: "无",
codecRowLabel: "解码",
viewRowLabel: "视图",
codecBase64: "Base64 解码",
unicodeJsonView: "Unicode JSON",
codecMismatch: "无法按所选解码方式还原,已显示原始内容",
decompressedView: "解压视图",
decompressedLoading: "解压中…",
decompressedFailed: "无法按所选格式解压",
+6
View File
@@ -3706,6 +3706,12 @@ export default withEnglishFallback({
jsonViewDecoded: "解碼",
rawContent: "原始內容",
wordWrap: "自動換行",
codecNone: "無",
codecRowLabel: "解碼",
viewRowLabel: "檢視",
codecBase64: "Base64 解碼",
codecMismatch: "無法按所選解碼方式還原,已顯示原始內容",
unicodeJsonView: "Unicode JSON",
decompressedView: "解壓檢視",
decompressedLoading: "解壓中…",
decompressedFailed: "無法按所選格式解壓",
@@ -122,7 +122,7 @@ describe("native RedisJSON editor", () => {
});
it("uses the same foldable source editor as JSON strings and hash fields", () => {
const stringEditor = findTemplateElement((element) => element.tag === "RedisJsonEditor" && directiveExpression(element, "if") === "stringValueView === 'json' && stringValueDetail.json");
const stringEditor = findTemplateElement((element) => element.tag === "RedisJsonEditor" && directiveExpression(element, "if") === "stringValueView === 'json' && stringValueDetail.json && stringValueCodec === 'none'");
const nativeJsonBranch = findTemplateElement((element) => directiveExpression(element, "else-if") === "redisKind === 'json'");
const hashEditor = findTemplateElement((element) => element.tag === "RedisJsonEditor" && directiveExpression(element, "if") === "isEditingHashJson");
const nativeJsonEditors = templateElements(nativeJsonBranch).filter((element) => element.tag === "RedisJsonEditor");
@@ -257,8 +257,8 @@ describe("native RedisJSON editor", () => {
expect(unsavedDraft.getText()).toContain("hasRetainedStringDraft.value");
expect(unsavedDraft.getText()).toContain("hasRetainedMemberDraft.value");
expect(textFormat.getText()).toContain('format === "json"');
expect(labels.some((element) => element.tag === "label" && directiveExpression(element, "if") === "isTextRedisFormat(stringValueView)")).toBe(true);
expect(labels.some((element) => element.tag === "label" && directiveExpression(element, "if") === "isTextRedisFormat(memberValueView)")).toBe(true);
expect(labels.some((element) => element.tag === "label" && directiveExpression(element, "if") === "isTextRedisFormat(stringValueView) || activeStructuredStringDetail || isDecompressCodec(stringValueCodec)")).toBe(true);
expect(labels.some((element) => element.tag === "label" && directiveExpression(element, "if") === "isTextRedisFormat(memberValueView) || activeStructuredMemberDetail || isDecompressCodec(memberValueCodec)")).toBe(true);
expect(directiveExpression(stringTextarea, "bind", "readonly")).toBe("!canEditCurrentStringFormat || savingString");
expect(directiveExpression(memberTextarea, "bind", "readonly")).toBe("savingMember");
expect(directiveExpression(refreshButton, "bind", "disabled")).toBe("loading || refreshingValue || hasUnsavedRedisDraft");
@@ -5,6 +5,8 @@ import {
formatRedisMemberDetail,
formatRedisStringValue,
getRedisMemberSelectionKey,
jsonToXmlText,
jsonToYamlText,
normalizeRedisJsonDraft,
preferredRedisValueFormat,
redisClipboardSafeText,
@@ -227,7 +229,7 @@ describe("redisValuePresentation", () => {
},
{ allowJsonText: true },
).availableFormats,
).toEqual(["utf8", "ascii", "binary", "json", "hex", "base64"]);
).toEqual(["utf8", "ascii", "binary", "json", "unicodejson", "yaml", "xml", "hex", "base64"]);
});
it("falls back to utf8 when a binary inspection view was stored for editable text", () => {
@@ -242,17 +244,29 @@ describe("redisValuePresentation", () => {
expect(preferredRedisValueFormat(blob, "json", { allowJsonText: true })).toBe("utf8");
});
it("adds a Java serialized format when the blob uses Java object serialization", () => {
it("keeps Java serialized payloads on the codec axis, not the view tabs", () => {
const detail = formatRedisMemberDetail({
raw_base64: "rO0ABXQACHNvbWV0ZXh0",
encoding: "binary",
});
expect(detail.availableFormats).toEqual(["javaserialize", "binary", "hex", "base64"]);
expect(detail.defaultFormat).toBe("javaserialize");
expect(detail.availableFormats).toEqual(["hex", "binary", "base64"]);
expect(detail.defaultFormat).toBe("hex");
expect(detail.defaultCodec).toBe("none");
expect(detail.availableCodecs).toContain("javaserialize");
expect(detail.javaSerialized?.formattedText).toBe('"sometext"');
expect(canRenderRedisValueFormat(detail, "javaserialize")).toBe(true);
expect(canRenderRedisValueFormat(formatRedisMemberDetail("plain-text"), "javaserialize")).toBe(false);
expect(canRenderRedisValueFormat(detail, "json")).toBe(false);
expect(canRenderRedisValueFormat(formatRedisMemberDetail("plain-text"), "utf8")).toBe(true);
});
it("keeps legacy Pickle payloads out of conservative auto-detection", () => {
const detail = formatRedisMemberDetail({
raw_base64: "KGRwMApWc3RhdHVzCnAxClZTVUNDRVNTCnAyCnNWcmVzdWx0CnAzCihscDQKSTEKYVZoZWxsbwpwNQphTmFJMDEKYUkwMAphcy4=",
encoding: "binary",
});
expect(detail.pickle).toBeUndefined();
expect(detail.defaultCodec).toBe("none");
});
it("keeps self-referential Java maps representable via refs", () => {
@@ -285,4 +299,33 @@ describe("redisValuePresentation", () => {
}),
).toBe("\\xac\\xed\\x00\\x05");
});
it("renders JSON values as YAML", () => {
expect(
jsonToYamlText({
id: 1,
name: "Ada",
tags: ["redis", "db"],
meta: { host: "localhost", port: 6379 },
empty: {},
items: [],
}),
).toBe("id: 1\nname: Ada\ntags:\n - redis\n - db\nmeta:\n host: localhost\n port: 6379\nempty: {}\nitems: []\n");
});
it("quotes YAML scalars that would otherwise change type", () => {
expect(jsonToYamlText({ port: "6379", flag: "true", label: "hello world", none: "null" })).toBe('port: "6379"\nflag: "true"\nlabel: "hello world"\nnone: "null"\n');
});
it("renders JSON values as XML", () => {
expect(jsonToXmlText({ id: 1, name: "Ada", tags: ["redis", "db"] })).toBe('<?xml version="1.0" encoding="UTF-8"?>\n<root>\n <id>1</id>\n <name>Ada</name>\n <tags>redis</tags>\n <tags>db</tags>\n</root>');
});
it("escapes XML text and sanitizes unsafe tag names", () => {
expect(jsonToXmlText({ query: 'a < "b" & c', "1st": "entry" })).toBe('<?xml version="1.0" encoding="UTF-8"?>\n<root>\n <query>a &lt; &quot;b&quot; &amp; c</query>\n <_1st>entry</_1st>\n</root>');
});
it("uses a valid fallback tag for empty JSON keys", () => {
expect(jsonToXmlText({ "": 1 })).toBe('<?xml version="1.0" encoding="UTF-8"?>\n<root>\n <item>1</item>\n</root>');
});
});
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { decodeBase64RedisValue } from "../base64";
function bytes(text: string): Uint8Array {
return new TextEncoder().encode(text);
}
describe("decodeBase64RedisValue", () => {
it("decodes padded and unpadded base64 text", () => {
expect(decodeBase64RedisValue(bytes("eyJpZCI6MX0="))).toBe('{"id":1}');
expect(decodeBase64RedisValue(bytes("eyJpZCI6MX0"))).toBe('{"id":1}');
});
it("ignores wrapping newlines", () => {
expect(decodeBase64RedisValue(bytes("eyJpZA\n=="))).toBe('{"id');
});
it("rejects plain text that is not clean base64", () => {
expect(decodeBase64RedisValue(bytes("hello world!"))).toBeNull();
expect(decodeBase64RedisValue(bytes("{"))).toBeNull();
expect(decodeBase64RedisValue(bytes(""))).toBeNull();
});
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { deflateRawSync, deflateSync, gzipSync } from "zlib";
import { decompressRedisValue, isGzipMagic, REDIS_DECOMPRESS_MAX_OUTPUT_BYTES } from "../redisCompression";
import { decompressRedisValue, isGzipMagic, REDIS_DECOMPRESS_MAX_OUTPUT_BYTES } from "../compression";
// Decompression runs against the real pako implementation (pure JS), so these
// tests exercise the actual production path in Node the same way it runs in the
@@ -0,0 +1,16 @@
import { encode } from "@msgpack/msgpack";
import { describe, expect, it } from "vitest";
import { decodeMsgpack } from "../msgpack";
describe("decodeMsgpack", () => {
it("decodes objects, arrays, and scalars", () => {
expect(decodeMsgpack(encode({ task: "add", value: 3 }))?.value).toEqual({ task: "add", value: 3 });
expect(decodeMsgpack(encode(["hello", 1, null, true]))?.value).toEqual(["hello", 1, null, true]);
expect(decodeMsgpack(encode("plain"))?.value).toBe("plain");
});
it("rejects empty and corrupt payloads instead of guessing", () => {
expect(decodeMsgpack(new Uint8Array(0))).toBeNull();
expect(decodeMsgpack(new Uint8Array([0xc1]))).toBeNull();
});
});
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { decodePhpSerialized } from "../phpSerialize";
function bytes(text: string): Uint8Array {
return new TextEncoder().encode(text);
}
describe("decodePhpSerialized", () => {
it("decodes arrays, maps, and scalars", () => {
expect(decodePhpSerialized(bytes('a:3:{i:0;s:9:"hello dbx";i:1;i:42;i:2;b:1;}'))?.value).toEqual(["hello dbx", 42, true]);
expect(decodePhpSerialized(bytes('a:2:{s:3:"msg";s:9:"hello dbx";s:5:"count";d:1.5;}'))?.value).toEqual({ msg: "hello dbx", count: 1.5 });
expect(decodePhpSerialized(bytes("N;"))?.value).toBeNull();
expect(decodePhpSerialized(bytes('s:9:"hello dbx";'))?.value).toBe("hello dbx");
});
it("decodes objects with their class name", () => {
expect(decodePhpSerialized(bytes('O:8:"JobState":2:{s:5:"state";s:4:"done";s:7:"retries";i:0;}'))?.value).toEqual({
$class: "JobState",
state: "done",
retries: 0,
});
});
it("measures string lengths in bytes, not characters", () => {
// "你好" is 2 characters but 6 UTF-8 bytes.
expect(decodePhpSerialized(bytes('s:6:"你好";'))?.value).toBe("你好");
expect(decodePhpSerialized(bytes('a:1:{s:5:"label";s:6:"你好";}'))?.value).toEqual({ label: "你好" });
});
it("decodes nested structures", () => {
expect(decodePhpSerialized(bytes('a:1:{s:5:"outer";a:2:{i:0;s:3:"one";i:1;a:1:{i:0;i:7;}}}?'))).toBeNull();
expect(decodePhpSerialized(bytes('a:1:{s:5:"outer";a:2:{i:0;s:3:"one";i:1;a:1:{i:0;i:7;}}}'))?.value).toEqual({ outer: ["one", [7]] });
});
it("rejects non-serialized payloads and truncation", () => {
expect(decodePhpSerialized(bytes("hello dbx"))).toBeNull();
expect(decodePhpSerialized(bytes('a:2:{i:0;s:9:"hello dbx";}'))).toBeNull();
expect(decodePhpSerialized(bytes('s:99:"short";'))).toBeNull();
});
});
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { decodePickle, isPickleMagic } from "../pickle";
function blob(base64: string): Uint8Array {
return Uint8Array.from(Buffer.from(base64, "base64"));
}
describe("decodePickle", () => {
it("rejects non-pickle payloads", () => {
expect(isPickleMagic(new Uint8Array([0x7b, 0x7d]))).toBe(false);
expect(decodePickle(new TextEncoder().encode("not pickle"))).toBeNull();
expect(decodePickle(new Uint8Array(0))).toBeNull();
});
it("decodes a Celery SUCCESS result from protocol 4", () => {
const detail = decodePickle(blob("gASViwAAAAAAAAB9lCiMBnN0YXR1c5SMB1NVQ0NFU1OUjAZyZXN1bHSUfZQojAR0YXNrlIwDYWRklIwFdmFsdWWUSwN1jAl0cmFjZWJhY2uUTowIY2hpbGRyZW6UXZSMCWRhdGVfZG9uZZSMCGRhdGV0aW1llIwIZGF0ZXRpbWWUk5RDCgfoBhgMAAAAAACUhZRSlHUu"));
expect(detail).not.toBeNull();
expect(detail?.value).toEqual({
status: "SUCCESS",
result: { task: "add", value: 3 },
traceback: null,
children: [],
date_done: "2024-06-24T12:00:00",
});
});
it("decodes the same Celery result from protocol 2", () => {
const detail = decodePickle(
blob(
"gAJ9cQAoWAYAAABzdGF0dXNxAVgHAAAAU1VDQ0VTU3ECWAYAAAByZXN1bHRxA31xBChYBAAAAHRhc2txBVgDAAAAYWRkcQZYBQAAAHZhbHVlcQdLA3VYCQAAAHRyYWNlYmFja3EITlgIAAAAY2hpbGRyZW5xCV1xClgJAAAAZGF0ZV9kb25lcQtjZGF0ZXRpbWUKZGF0ZXRpbWUKcQxjX2NvZGVjcwplbmNvZGUKcQ1YCwAAAAfDqAYYDAAAAAAAcQ5YBgAAAGxhdGluMXEPhnEQUnERhXESUnETdS4=",
),
);
expect(detail?.value).toEqual({
status: "SUCCESS",
result: { task: "add", value: 3 },
traceback: null,
children: [],
date_done: "2024-06-24T12:00:00",
});
});
it.each([
[0, "KGRwMApWc3RhdHVzCnAxClZTVUNDRVNTCnAyCnNWcmVzdWx0CnAzCihscDQKSTEKYVZoZWxsbwpwNQphTmFJMDEKYUkwMAphcy4="],
[1, "fXEAKFgGAAAAc3RhdHVzcQFYBwAAAFNVQ0NFU1NxAlgGAAAAcmVzdWx0cQNdcQQoSwFYBQAAAGhlbGxvcQVOSTAxCkkwMApldS4="],
])("explicitly decodes protocol %i without relaxing magic detection", (_protocol, fixture) => {
const bytes = blob(fixture);
expect(isPickleMagic(bytes)).toBe(false);
expect(decodePickle(bytes)?.value).toEqual({
status: "SUCCESS",
result: [1, "hello", null, true, false],
});
});
it("decodes nested tuples, lists, and dicts", () => {
expect(decodePickle(blob("gASVIwAAAAAAAAB9lCiMBGFyZ3OUSwFLAoaUjAZrd2FyZ3OUfZSMAXiUiHN1Lg=="))?.value).toEqual({
args: [1, 2],
kwargs: { x: true },
});
expect(decodePickle(blob("gASVEgAAAAAAAABdlChLAYwFaGVsbG+UToiJZS4="))?.value).toEqual([1, "hello", null, true, false]);
});
it("decodes uuid and text scalars", () => {
expect(decodePickle(blob("gASVEQAAAAAAAACMDWNlbGVyeS1yZXN1bHSULg=="))?.value).toBe("celery-result");
expect(decodePickle(blob("gASVMAAAAAAAAACMBHV1aWSUjARVVUlElJOUKYGUfZSMA2ludJSKEHhWNBJ4VjQSeFY0EnhWNBJzYi4="))?.value).toBe("12345678-1234-5678-1234-567812345678");
});
it("does not execute GLOBAL callables; unknown classes stay as data", () => {
expect(decodePickle(blob("gASVHwAAAAAAAACMCGJ1aWx0aW5zlIwEZXZhbJSTlIwDMSsxlIWUUpQu"))?.value).toEqual({
$class: "builtins.eval",
$args: ["1+1"],
});
expect(decodePickle(blob("gASVHQAAAAAAAACMCGJ1aWx0aW5zlIwDYWJzlJOUSv3///+FlFKULg=="))?.value).toEqual({
$class: "builtins.abs",
$args: [-3],
});
});
});
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { decodeProtobuf } from "../protobuf";
describe("decodeProtobuf", () => {
it("decodes varints and length-delimited strings", () => {
// field 1 varint 150, field 2 string "hello dbx" — canonical proto3 encoding
const payload = Uint8Array.from([0x08, 0x96, 0x01, 0x12, 0x09, ...new TextEncoder().encode("hello dbx")]);
expect(decodeProtobuf(payload)?.value).toEqual({ 1: 150, 2: "hello dbx" });
});
it("renders repeated fields as arrays", () => {
// field 3 string "a", field 3 string "b"
const payload = Uint8Array.from([0x1a, 0x01, 0x61, 0x1a, 0x01, 0x62]);
expect(decodeProtobuf(payload)?.value).toEqual({ 3: ["a", "b"] });
});
it("decodes nested messages when the payload parses as one", () => {
// outer field 1 = nested {field 1 varint 7}
const payload = Uint8Array.from([0x0a, 0x02, 0x08, 0x07]);
expect(decodeProtobuf(payload)?.value).toEqual({ 1: { 1: 7 } });
});
it("renders non-text delimited bytes as hex", () => {
// field 1 = bytes [0x00, 0xff] — NUL fails the text check
const payload = Uint8Array.from([0x0a, 0x02, 0x00, 0xff]);
expect(decodeProtobuf(payload)?.value).toEqual({ 1: { $bytes: "00ff" } });
});
it("keeps large varints exact", () => {
// field 1 = 2^62
const payload = Uint8Array.from([0x08, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x40]);
expect(decodeProtobuf(payload)?.value).toEqual({ 1: "4611686018427387904" });
});
it("rejects empty and truncated payloads", () => {
expect(decodeProtobuf(new Uint8Array())).toBeNull();
// field 1 length-delimited claiming 9 bytes but only 4 follow
expect(decodeProtobuf(Uint8Array.from([0x0a, 0x09, 0x68, 0x65, 0x6c, 0x6c]))).toBeNull();
});
});
@@ -0,0 +1,16 @@
const BASE64_TEXT_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
/**
* Strict, user-selected Base64 decode for the codec axis: returns the decoded
* content as text, or null when the stored bytes are not clean Base64 (this is
* deliberately not auto-detected because many plain ASCII strings decode fine
* into garbage bytes).
*/
export function decodeBase64RedisValue(bytes: Uint8Array): string | null {
if (typeof atob !== "function") return null;
const source = new TextDecoder().decode(bytes).replace(/[\r\n]/g, "");
if (!source || source.length % 4 === 1 || !BASE64_TEXT_PATTERN.test(source)) return null;
const binary = atob(source);
const decoded = Uint8Array.from(binary, (char) => char.charCodeAt(0));
return new TextDecoder().decode(decoded);
}
@@ -0,0 +1,7 @@
export { decodeMsgpack, type RedisMsgpackDetail } from "./msgpack";
export { decodePickle, isPickleMagic, type RedisPickleDetail } from "./pickle";
export { parseJavaSerializedDetail, isJavaSerialized, type RedisJavaSerializedDetail } from "./javaSerialized";
export { decompressRedisValue, isGzipMagic, REDIS_DECOMPRESS_MAX_OUTPUT_BYTES, type RedisDecompressAlgorithm, type RedisDecompressResult } from "./compression";
export { decodeBase64RedisValue } from "./base64";
export { decodePhpSerialized, isPhpSerialized, type RedisPhpSerializedDetail } from "./phpSerialize";
export { decodeProtobuf, type RedisProtobufDetail } from "./protobuf";
@@ -0,0 +1,55 @@
import { decode as msgpackDecode } from "@msgpack/msgpack";
export interface RedisMsgpackDetail {
formattedText: string;
value: unknown;
}
export function decodeMsgpack(bytes: Uint8Array): RedisMsgpackDetail | null {
if (bytes.length === 0) return null;
try {
const value = toJsonable(msgpackDecode(bytes));
return {
value,
formattedText: typeof value === "string" ? value : JSON.stringify(value, null, 2),
};
} catch {
return null;
}
}
function toJsonable(value: unknown, seen = new WeakSet<object>()): unknown {
if (value instanceof Uint8Array) {
try {
return new TextDecoder("utf-8", { fatal: true }).decode(value);
} catch {
return { $bytes: bytesToBase64(value) };
}
}
if (typeof value === "bigint") {
if (value <= BigInt(Number.MAX_SAFE_INTEGER) && value >= BigInt(Number.MIN_SAFE_INTEGER)) return Number(value);
return value.toString();
}
if (value instanceof Map) {
const output: Record<string, unknown> = {};
for (const [key, item] of value) output[String(toJsonable(key, seen))] = toJsonable(item, seen);
return output;
}
if (Array.isArray(value)) return value.map((item) => toJsonable(item, seen));
if (value && typeof value === "object") {
if (seen.has(value)) return { $ref: true };
seen.add(value);
const output: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) output[key] = toJsonable(item, seen);
return output;
}
return value;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
}
@@ -0,0 +1,158 @@
/**
* Data-only PHP serialize decoder. `s:<len>` counts bytes, not characters, so
* the parser walks the raw bytes and only converts complete runs to UTF-8 text.
*/
export interface RedisPhpSerializedDetail {
formattedText: string;
value: unknown;
}
const HEADER = /^(?:a|O|b|i|d|s|N)[:;]/;
class PhpUnserializer {
private pos = 0;
constructor(private readonly bytes: Uint8Array) {}
parse(): unknown {
const value = this.value();
if (this.pos !== this.bytes.length) throw new Error("trailing bytes");
return value;
}
private value(): unknown {
const type = String.fromCharCode(this.bytes[this.pos] ?? 0);
switch (type) {
case "N":
this.expect("N;");
return null;
case "b":
this.expect("b:");
return this.integer(";") === 1;
case "i":
this.expect("i:");
return this.integer(";");
case "d":
this.expect("d:");
return this.real(";");
case "s":
return this.text();
case "a":
return this.array();
case "O":
return this.object();
default:
throw new Error(`unsupported type "${type}"`);
}
}
private literal(token: string): boolean {
for (let i = 0; i < token.length; i++) {
if (this.bytes[this.pos + i] !== token.charCodeAt(i)) return false;
}
this.pos += token.length;
return true;
}
private integer(terminator: string): number {
const start = this.pos;
this.skipUntil(terminator);
const text = this.decode(start, this.pos);
if (!/^-?\d+$/.test(text)) throw new Error(`invalid integer "${text}"`);
this.expect(terminator);
return Number(text);
}
private real(terminator: string): number {
const start = this.pos;
this.skipUntil(terminator);
const text = this.decode(start, this.pos);
this.expect(terminator);
if (text === "INF") return Number.POSITIVE_INFINITY;
if (text === "-INF") return Number.NEGATIVE_INFINITY;
if (text === "NAN") return Number.NaN;
if (!/^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(text)) throw new Error(`invalid double "${text}"`);
return Number(text);
}
private text(): string {
if (!this.literal("s:")) throw new Error("expected string header");
return this.quotedText('";');
}
/** `<len>:"<bytes>"` — the shared body of `s:` values and `O:` class names. */
private quotedText(terminator: string): string {
const start = this.pos;
this.skipUntil(":");
const lengthText = this.decode(start, this.pos);
if (!/^\d+$/.test(lengthText)) throw new Error(`invalid string length "${lengthText}"`);
this.expect(":");
this.expect('"');
const length = Number(lengthText);
if (this.pos + length + terminator.length > this.bytes.length) throw new Error("string length overruns payload");
const text = this.decode(this.pos, this.pos + length);
this.pos += length;
if (!this.literal(terminator)) throw new Error("expected string terminator");
return text;
}
private array(): unknown {
if (!this.literal("a:")) throw new Error("expected array header");
const length = this.integer(":{");
const entries = new Map<unknown, unknown>();
for (let i = 0; i < length; i++) entries.set(this.value(), this.value());
this.expect("}");
// Sequential integer keys render as a plain array, everything else as a map.
let sequential = true;
let index = 0;
for (const key of entries.keys()) {
if (key !== index++) {
sequential = false;
break;
}
}
if (!sequential) return Object.fromEntries([...entries].map(([key, value]) => [String(key), value]));
return [...entries.values()];
}
private object(): unknown {
if (!this.literal("O:")) throw new Error("expected object header");
const className = this.quotedText('":');
const length = this.integer(":{");
const props: Record<string, unknown> = { $class: className };
for (let i = 0; i < length; i++) {
const name = this.value();
if (typeof name !== "string") throw new Error("object property names must be strings");
props[name] = this.value();
}
this.expect("}");
return props;
}
private skipUntil(terminator: string): void {
const code = terminator.charCodeAt(0);
while (this.pos < this.bytes.length && this.bytes[this.pos] !== code) this.pos++;
}
private expect(token: string): void {
if (!this.literal(token)) throw new Error(`expected "${token}" at byte ${this.pos}`);
}
private decode(start: number, end: number): string {
return new TextDecoder().decode(this.bytes.subarray(start, end));
}
}
export function isPhpSerialized(bytes: Uint8Array): boolean {
return HEADER.test(new TextDecoder().decode(bytes.subarray(0, 2)));
}
export function decodePhpSerialized(bytes: Uint8Array): RedisPhpSerializedDetail | null {
if (bytes.length === 0 || !isPhpSerialized(bytes)) return null;
try {
const value = new PhpUnserializer(bytes).parse();
return { formattedText: JSON.stringify(value, null, 2), value };
} catch {
return null;
}
}
+651
View File
@@ -0,0 +1,651 @@
export interface RedisPickleDetail {
formattedText: string;
value: unknown;
}
const PROTO = 0x80;
const NEWOBJ = 0x81;
const TUPLE1 = 0x85;
const TUPLE2 = 0x86;
const TUPLE3 = 0x87;
const NEWTRUE = 0x88;
const NEWFALSE = 0x89;
const LONG1 = 0x8a;
const LONG4 = 0x8b;
const SHORT_BINUNICODE = 0x8c;
const BINUNICODE8 = 0x8d;
const BINBYTES8 = 0x8e;
const EMPTY_SET = 0x8f;
const ADDITEMS = 0x90;
const FROZENSET = 0x91;
const NEWOBJ_EX = 0x92;
const STACK_GLOBAL = 0x93;
const MEMOIZE = 0x94;
const FRAME = 0x95;
const BYTEARRAY8 = 0x96;
const SHORT_BINBYTES = 0x43;
const BINBYTES = 0x42;
const BININT = 0x4a;
const BININT1 = 0x4b;
const BININT2 = 0x4d;
const BINFLOAT = 0x47;
const BINUNICODE = 0x58;
const BINSTRING = 0x54;
const SHORT_BINSTRING = 0x55;
const BINPUT = 0x71;
const LONG_BINPUT = 0x72;
const BINGET = 0x68;
const LONG_BINGET = 0x6a;
const MARK = 0x28;
const STOP = 0x2e;
const POP = 0x30;
const POP_MARK = 0x31;
const DUP = 0x32;
const EMPTY_TUPLE = 0x29;
const EMPTY_LIST = 0x5d;
const EMPTY_DICT = 0x7d;
const APPEND = 0x61;
const BUILD = 0x62;
const GLOBAL = 0x63;
const DICT = 0x64;
const APPENDS = 0x65;
const GET = 0x67;
const INST = 0x69;
const LIST = 0x6c;
const PUT = 0x70;
const SETITEM = 0x73;
const TUPLE = 0x74;
const SETITEMS = 0x75;
const NONE = 0x4e;
const REDUCE = 0x52;
const FLOAT = 0x46;
const INT = 0x49;
const LONG = 0x4c;
const STRING = 0x53;
const UNICODE = 0x56;
const OBJ = 0x6f;
const MAX_OPS = 1_000_000;
class PickleGlobal {
constructor(
readonly module: string,
readonly name: string,
) {}
get id(): string {
return `${this.module}.${this.name}`;
}
}
class PickleInstance {
state: unknown = null;
constructor(
readonly className: string,
readonly args: unknown,
) {}
}
class PickleUnpickler {
private readonly view: DataView;
private pos = 0;
private readonly stack: unknown[] = [];
private readonly metastack: unknown[][] = [];
private readonly memo = new Map<number, unknown>();
private nextMemoId = 0;
private ops = 0;
constructor(private readonly bytes: Uint8Array) {
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
}
load(): unknown {
while (this.pos < this.bytes.length) {
if (++this.ops > MAX_OPS) throw new Error("pickle too complex");
const opcode = this.readByte();
if (opcode === STOP) break;
this.dispatch(opcode);
}
if (this.stack.length === 0) throw new Error("empty pickle");
return this.stack[this.stack.length - 1];
}
private dispatch(opcode: number): void {
switch (opcode) {
case PROTO:
if (this.readByte() > 5) throw new Error("unsupported pickle protocol");
return;
case FRAME:
this.read(8);
return;
case NONE:
this.stack.push(null);
return;
case NEWTRUE:
this.stack.push(true);
return;
case NEWFALSE:
this.stack.push(false);
return;
case INT:
this.stack.push(parsePickleInt(this.readLine()));
return;
case BININT:
this.stack.push(this.view.getInt32(this.advance(4), true));
return;
case BININT1:
this.stack.push(this.readByte());
return;
case BININT2:
this.stack.push(this.view.getUint16(this.advance(2), true));
return;
case LONG:
this.stack.push(parsePickleLong(this.readLine()));
return;
case LONG1:
this.stack.push(this.readLong(this.readByte()));
return;
case LONG4:
this.stack.push(this.readLong(this.view.getInt32(this.advance(4), true)));
return;
case FLOAT:
this.stack.push(Number(this.readLine()));
return;
case BINFLOAT:
this.stack.push(this.view.getFloat64(this.advance(8), false));
return;
case BINUNICODE:
this.stack.push(this.readUtf8(this.view.getUint32(this.advance(4), true)));
return;
case SHORT_BINUNICODE:
this.stack.push(this.readUtf8(this.readByte()));
return;
case BINUNICODE8:
this.stack.push(this.readUtf8(this.readUint64()));
return;
case UNICODE:
this.stack.push(decodeUnicodeEscape(this.readLine()));
return;
case STRING:
this.stack.push(parsePickleString(this.readLine()));
return;
case BINSTRING:
this.stack.push(latin1FromBytes(this.read(this.view.getUint32(this.advance(4), true))));
return;
case SHORT_BINSTRING:
this.stack.push(latin1FromBytes(this.read(this.readByte())));
return;
case BINBYTES:
this.stack.push(this.read(this.view.getUint32(this.advance(4), true)));
return;
case SHORT_BINBYTES:
this.stack.push(this.read(this.readByte()));
return;
case BINBYTES8:
case BYTEARRAY8:
this.stack.push(this.read(this.readUint64()));
return;
case EMPTY_LIST:
this.stack.push([]);
return;
case EMPTY_DICT:
this.stack.push(Object.create(null));
return;
case EMPTY_TUPLE:
this.stack.push([]);
return;
case EMPTY_SET:
this.stack.push([]);
return;
case MARK:
this.metastack.push(this.stack.splice(0, this.stack.length));
return;
case POP:
this.pop();
return;
case POP_MARK:
this.popMark();
return;
case DUP:
this.stack.push(this.stack[this.stack.length - 1]);
return;
case APPEND:
this.appendOne();
return;
case APPENDS:
this.appends();
return;
case SETITEM:
this.setItem();
return;
case SETITEMS:
this.setItems();
return;
case ADDITEMS:
this.addItems();
return;
case LIST:
case TUPLE:
case FROZENSET:
this.stack.push(this.popMark());
return;
case DICT:
this.stack.push(pairsToObject(this.popMark()));
return;
case TUPLE1:
this.stack.push([this.pop()]);
return;
case TUPLE2: {
const b = this.pop();
const a = this.pop();
this.stack.push([a, b]);
return;
}
case TUPLE3: {
const c = this.pop();
const b = this.pop();
const a = this.pop();
this.stack.push([a, b, c]);
return;
}
case GLOBAL:
this.stack.push(new PickleGlobal(this.readLine(), this.readLine()));
return;
case STACK_GLOBAL: {
const name = this.pop();
const module = this.pop();
this.stack.push(new PickleGlobal(String(module), String(name)));
return;
}
case REDUCE:
this.reduce();
return;
case NEWOBJ:
this.newObj(false);
return;
case NEWOBJ_EX:
this.newObj(true);
return;
case BUILD:
this.build();
return;
case INST:
this.stack.push(new PickleInstance(`${this.readLine()}.${this.readLine()}`, this.popMark()));
return;
case OBJ: {
const items = this.popMark();
const cls = items.shift();
this.stack.push(new PickleInstance(classNameOf(cls), items));
return;
}
case MEMOIZE:
this.memo.set(this.nextMemoId++, this.peek());
return;
case BINPUT:
this.memo.set(this.readByte(), this.peek());
return;
case LONG_BINPUT:
this.memo.set(this.view.getUint32(this.advance(4), true), this.peek());
return;
case BINGET:
this.stack.push(this.memoGet(this.readByte()));
return;
case LONG_BINGET:
this.stack.push(this.memoGet(this.view.getUint32(this.advance(4), true)));
return;
case PUT:
this.memo.set(Number(this.readLine()), this.peek());
return;
case GET:
this.stack.push(this.memoGet(Number(this.readLine())));
return;
default:
throw new Error(`unsupported pickle opcode 0x${opcode.toString(16)}`);
}
}
private reduce(): void {
const args = this.pop();
const callable = this.pop();
this.stack.push(applyReduce(callable, Array.isArray(args) ? args : [args]));
}
private newObj(withKwargs: boolean): void {
const kwargs = withKwargs ? this.pop() : undefined;
const args = this.pop();
const cls = this.pop();
const instance = new PickleInstance(classNameOf(cls), Array.isArray(args) ? args : [args]);
if (kwargs != null) instance.state = kwargs;
this.stack.push(instance);
}
private build(): void {
const state = this.pop();
this.stack[this.stack.length - 1] = applyBuild(this.peek(), state);
}
private appendOne(): void {
const value = this.pop();
const list = this.peek();
if (!Array.isArray(list)) throw new Error("APPEND on non-list");
list.push(value);
}
private appends(): void {
const items = this.popMark();
const list = this.peek();
if (!Array.isArray(list)) throw new Error("APPENDS on non-list");
list.push(...items);
}
private addItems(): void {
const items = this.popMark();
const list = this.peek();
if (!Array.isArray(list)) throw new Error("ADDITEMS on non-set");
list.push(...items);
}
private setItem(): void {
const value = this.pop();
const key = this.pop();
writeKey(this.peek(), key, value);
}
private setItems(): void {
const items = this.popMark();
const dict = this.peek();
for (let i = 0; i + 1 < items.length; i += 2) writeKey(dict, items[i], items[i + 1]);
}
private popMark(): unknown[] {
const items = this.stack.splice(0, this.stack.length);
const parent = this.metastack.pop();
if (!parent) throw new Error("MARK underflow");
this.stack.push(...parent);
return items;
}
private pop(): unknown {
if (this.stack.length === 0) throw new Error("stack underflow");
return this.stack.pop();
}
private peek(): unknown {
if (this.stack.length === 0) throw new Error("stack underflow");
return this.stack[this.stack.length - 1];
}
private memoGet(id: number): unknown {
if (!this.memo.has(id)) throw new Error("missing pickle memo");
return this.memo.get(id);
}
private readByte(): number {
if (this.pos >= this.bytes.length) throw new Error("unexpected end of pickle");
return this.bytes[this.pos++];
}
private advance(n: number): number {
const start = this.pos;
if (start + n > this.bytes.length) throw new Error("unexpected end of pickle");
this.pos += n;
return start;
}
private read(n: number): Uint8Array {
const start = this.advance(n);
return this.bytes.subarray(start, start + n);
}
private readUint64(): number {
const start = this.advance(8);
const lo = this.view.getUint32(start, true);
const hi = this.view.getUint32(start + 4, true);
if (hi > 0x1fffff) throw new Error("pickle length too large");
return lo + hi * 2 ** 32;
}
private readUtf8(n: number): string {
return new TextDecoder("utf-8", { fatal: true }).decode(this.read(n));
}
private readLine(): string {
const start = this.pos;
while (this.pos < this.bytes.length && this.bytes[this.pos] !== 0x0a) this.pos++;
if (this.pos >= this.bytes.length) throw new Error("unterminated pickle line");
const line = this.bytes.subarray(start, this.pos);
this.pos++;
return new TextDecoder("latin1").decode(line);
}
private readLong(length: number): number | string {
if (length < 0) throw new Error("invalid pickle long");
return decodeTwoComplement(this.read(length));
}
}
function parsePickleInt(line: string): boolean | number {
if (line === "01") return true;
if (line === "00") return false;
const value = Number(line);
if (!Number.isFinite(value)) throw new Error("invalid pickle int");
return value;
}
function parsePickleLong(line: string): number | string {
const trimmed = line.endsWith("L") ? line.slice(0, -1) : line;
return bigintToJson(BigInt(trimmed || "0"));
}
function parsePickleString(line: string): string {
const trimmed = line.trim();
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function decodeUnicodeEscape(line: string): string {
return line.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)));
}
function classNameOf(value: unknown): string {
return value instanceof PickleGlobal ? value.id : String(value);
}
function applyReduce(callable: unknown, args: unknown[]): unknown {
const id = callable instanceof PickleGlobal ? callable.id : "";
switch (id) {
case "datetime.datetime":
return reduceDatetime(args);
case "datetime.date":
return reduceDate(args);
case "datetime.time":
return reduceTime(args);
case "_codecs.encode":
return reduceCodecsEncode(args);
case "builtins.bytes":
case "__builtin__.bytes":
case "builtins.bytearray":
return reduceBytes(args);
case "uuid.UUID":
return new PickleInstance(id, args);
default:
return { $class: id || String(callable), $args: toJsonable(args) };
}
}
function applyBuild(obj: unknown, state: unknown): unknown {
if (obj instanceof PickleInstance) {
obj.state = state;
if (obj.className === "uuid.UUID") return reduceUuidState(state);
return { $class: obj.className, $args: toJsonable(obj.args), $state: toJsonable(state) };
}
if (obj && typeof obj === "object" && !Array.isArray(obj) && state && typeof state === "object" && !Array.isArray(state)) {
return { ...(obj as object), ...(state as object) };
}
return obj;
}
function reduceDatetime(args: unknown[]): string {
if (args.length === 1 && args[0] instanceof Uint8Array) return datetimeFromPayload(args[0]);
if (typeof args[0] === "string") return datetimeFromPayload(latin1ToBytes(args[0]));
const [year, month, day, hour = 0, minute = 0, second = 0, micro = 0] = args.map((value) => Number(value));
return formatDatetime(year, month, day, hour, minute, second, micro);
}
function reduceDate(args: unknown[]): string {
const [year, month, day] = args.map((value) => Number(value));
return `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}`;
}
function reduceTime(args: unknown[]): string {
const [hour = 0, minute = 0, second = 0, micro = 0] = args.map((value) => Number(value));
return formatTime(hour, minute, second, micro);
}
function reduceCodecsEncode(args: unknown[]): unknown {
const [data, encoding] = args;
if (typeof data === "string" && encoding === "latin1") return latin1ToBytes(data);
return { $class: "_codecs.encode", $args: toJsonable(args) };
}
function reduceBytes(args: unknown[]): unknown {
if (args.length === 1 && args[0] instanceof Uint8Array) return args[0];
if (args.length === 1 && typeof args[0] === "string") return latin1ToBytes(args[0]);
return { $class: "bytes", $args: toJsonable(args) };
}
function reduceUuidState(state: unknown): unknown {
if (!state || typeof state !== "object") return { $class: "uuid.UUID", $state: toJsonable(state) };
const record = state as Record<string, unknown>;
if (record.int != null) return uuidFromInt(toBigInt(record.int));
if (typeof record.hex === "string") return normalizeUuid(record.hex);
return { $class: "uuid.UUID", $state: toJsonable(state) };
}
function datetimeFromPayload(bytes: Uint8Array): string {
if (bytes.length !== 10) throw new Error("invalid datetime payload");
const year = (bytes[0] << 8) | bytes[1];
const micro = (bytes[7] << 16) | (bytes[8] << 8) | bytes[9];
return formatDatetime(year, bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], micro);
}
function formatDatetime(year: number, month: number, day: number, hour: number, minute: number, second: number, micro: number): string {
return `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}T${formatTime(hour, minute, second, micro)}`;
}
function formatTime(hour: number, minute: number, second: number, micro: number): string {
const base = `${pad(hour, 2)}:${pad(minute, 2)}:${pad(second, 2)}`;
return micro ? `${base}.${pad(micro, 6)}` : base;
}
function pad(value: number, width: number): string {
return String(Math.trunc(value)).padStart(width, "0");
}
function uuidFromInt(value: bigint): string {
return normalizeUuid(value.toString(16).padStart(32, "0"));
}
function normalizeUuid(hex: string): string {
const digits = hex.replace(/-/g, "").padStart(32, "0").slice(-32);
return `${digits.slice(0, 8)}-${digits.slice(8, 12)}-${digits.slice(12, 16)}-${digits.slice(16, 20)}-${digits.slice(20)}`;
}
function writeKey(dict: unknown, key: unknown, value: unknown): void {
if (!dict || typeof dict !== "object" || Array.isArray(dict)) throw new Error("SETITEM on non-dict");
(dict as Record<string, unknown>)[keyToString(key)] = value;
}
function pairsToObject(items: unknown[]): Record<string, unknown> {
const dict = Object.create(null) as Record<string, unknown>;
for (let i = 0; i + 1 < items.length; i += 2) dict[keyToString(items[i])] = items[i + 1];
return dict;
}
function keyToString(key: unknown): string {
if (typeof key === "string") return key;
if (typeof key === "number" || typeof key === "boolean") return String(key);
return JSON.stringify(toJsonable(key)) ?? String(key);
}
function latin1FromBytes(bytes: Uint8Array): string {
return new TextDecoder("latin1").decode(bytes);
}
function latin1ToBytes(value: string): Uint8Array {
const bytes = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) bytes[i] = value.charCodeAt(i) & 0xff;
return bytes;
}
function decodeTwoComplement(bytes: Uint8Array): number | string {
if (bytes.length === 0) return 0;
let value = 0n;
for (let i = bytes.length - 1; i >= 0; i--) value = (value << 8n) | BigInt(bytes[i]);
const bits = BigInt(bytes.length * 8);
if (bytes[bytes.length - 1] & 0x80) value -= 1n << bits;
return bigintToJson(value);
}
function bigintToJson(value: bigint): number | string {
if (value <= BigInt(Number.MAX_SAFE_INTEGER) && value >= BigInt(Number.MIN_SAFE_INTEGER)) return Number(value);
return value.toString();
}
function toBigInt(value: unknown): bigint {
if (typeof value === "bigint") return value;
if (typeof value === "number") return BigInt(value);
if (typeof value === "string") return BigInt(value);
throw new Error("invalid uuid int");
}
export function toJsonable(value: unknown, seen = new WeakSet<object>()): unknown {
if (value instanceof Uint8Array) {
try {
return new TextDecoder("utf-8", { fatal: true }).decode(value);
} catch {
return { $bytes: bytesToBase64(value) };
}
}
if (value instanceof PickleGlobal) return { $class: value.id };
if (value instanceof PickleInstance) {
return { $class: value.className, $args: toJsonable(value.args, seen), $state: toJsonable(value.state, seen) };
}
if (Array.isArray(value)) return value.map((item) => toJsonable(item, seen));
if (value && typeof value === "object") {
if (seen.has(value)) return { $ref: true };
seen.add(value);
const output: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) output[key] = toJsonable(item, seen);
return output;
}
if (typeof value === "bigint") return bigintToJson(value);
return value;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
}
export function isPickleMagic(bytes: Uint8Array): boolean {
return bytes.length >= 3 && bytes[0] === PROTO && bytes[1] <= 5 && bytes[bytes.length - 1] === STOP;
}
export function decodePickle(bytes: Uint8Array): RedisPickleDetail | null {
try {
const value = toJsonable(new PickleUnpickler(bytes).load());
return {
value,
formattedText: typeof value === "string" ? value : JSON.stringify(value, null, 2),
};
} catch {
return null;
}
}
@@ -0,0 +1,116 @@
/**
* Schema-less Protobuf wire-format decoder. Without a .proto file field names
* are unknown, so fields render keyed by number; nested messages, UTF-8 text,
* and raw bytes are told apart heuristically. The decode is data-only and is
* never auto-detected — many arbitrary byte strings parse as valid wire format.
*/
export interface RedisProtobufDetail {
formattedText: string;
value: unknown;
}
class ProtobufReader {
private pos = 0;
constructor(private readonly bytes: Uint8Array) {}
atEnd(): boolean {
return this.pos >= this.bytes.length;
}
varint(): bigint {
let shift = 0n;
let value = 0n;
while (this.pos < this.bytes.length) {
const byte = this.bytes[this.pos++];
value |= BigInt(byte & 0x7f) << shift;
if ((byte & 0x80) === 0) return value;
shift += 7n;
if (shift > 63n) throw new Error("varint too long");
}
throw new Error("truncated varint");
}
fixed(size: number): Uint8Array {
if (this.pos + size > this.bytes.length) throw new Error("truncated fixed value");
const view = this.bytes.subarray(this.pos, this.pos + size);
this.pos += size;
return view;
}
delimited(): Uint8Array {
const length = Number(this.varint());
if (!Number.isSafeInteger(length) || this.pos + length > this.bytes.length) throw new Error("truncated length-delimited value");
const view = this.bytes.subarray(this.pos, this.pos + length);
this.pos += length;
return view;
}
}
function decodeMessage(bytes: Uint8Array): Record<string, unknown> {
const reader = new ProtobufReader(bytes);
const fields: Record<string, unknown> = {};
while (!reader.atEnd()) {
const tag = reader.varint();
if (tag > 0xffff_ffffn) throw new Error("field number too large");
const field = tag >> 3n;
const wireType = Number(tag & 7n);
if (field < 1n || wireType > 5 || wireType === 3 || wireType === 4) throw new Error("unsupported wire type");
let value: unknown;
if (wireType === 0) {
const raw = reader.varint();
value = raw <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(raw) : raw.toString();
} else if (wireType === 2) {
value = decodeDelimited(reader.delimited());
} else {
value = bytesToHex(reader.fixed(wireType === 1 ? 8 : 4));
}
const key = String(field);
const existing = fields[key];
if (existing === undefined) fields[key] = value;
else if (Array.isArray(existing)) existing.push(value);
else fields[key] = [existing, value];
}
if (Object.keys(fields).length === 0) throw new Error("empty message");
return fields;
}
function decodeDelimited(bytes: Uint8Array): unknown {
if (bytes.length > 0) {
try {
return decodeMessage(bytes);
} catch {
// Not a nested message; fall through to text/bytes rendering.
}
}
try {
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
// Embedded controls (other than common whitespace) mean bytes, not text.
let printable = true;
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
if (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) {
printable = false;
break;
}
}
if (printable) return text;
} catch {
// Not valid UTF-8; fall through to bytes rendering.
}
return { $bytes: Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("") };
}
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
export function decodeProtobuf(bytes: Uint8Array): RedisProtobufDetail | null {
if (bytes.length === 0) return null;
try {
const value = decodeMessage(bytes);
return { formattedText: JSON.stringify(value, null, 2), value };
} catch {
return null;
}
}
@@ -1,13 +1,38 @@
import type { BinaryHexViewRow } from "@/lib/dataGrid/binaryHexViewer";
import { buildBinaryHexViewRows } from "@/lib/dataGrid/binaryHexViewer";
import { formatJsonSource, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
import { decodeJsonUnicodeEscapes, formatJsonSource, isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
import type { RedisBlob, RedisCollectionPage, RedisHashItem, RedisListItem, RedisSetItem, RedisValue, RedisZsetItem } from "@/lib/backend/api";
import { parseJavaSerializedDetail, type RedisJavaSerializedDetail } from "@/lib/redis/javaSerialized";
import { decodeMsgpack, decodePhpSerialized, decodePickle, isPickleMagic, parseJavaSerializedDetail, type RedisJavaSerializedDetail, type RedisMsgpackDetail, type RedisPhpSerializedDetail, type RedisPickleDetail } from "@/lib/redis/codec";
export type RedisValueFormat = "utf8" | "ascii" | "binary" | "json" | "javaserialize" | "hex" | "base64" | "decompressed";
export type RedisValueView = "utf8" | "ascii" | "binary" | "json" | "unicodejson" | "yaml" | "xml" | "hex" | "base64";
export type RedisValueCodec = "none" | "gzip" | "zlib" | "deflate" | "base64" | "msgpack" | "pickle" | "phpserialize" | "protobuf" | "javaserialize";
export type RedisValueFormat = RedisValueView;
export type RedisMemberDetailFormat = "json" | "text";
export const REDIS_VALUE_FORMAT_DISPLAY_ORDER: RedisValueFormat[] = ["utf8", "ascii", "binary", "json", "javaserialize", "hex", "base64", "decompressed"];
export const REDIS_VALUE_VIEW_DISPLAY_ORDER: RedisValueView[] = ["utf8", "ascii", "binary", "json", "unicodejson", "yaml", "xml", "hex", "base64"];
export const REDIS_VALUE_FORMAT_DISPLAY_ORDER: RedisValueFormat[] = REDIS_VALUE_VIEW_DISPLAY_ORDER;
export const REDIS_VALUE_CODEC_ORDER: RedisValueCodec[] = ["none", "gzip", "zlib", "deflate", "base64", "msgpack", "pickle", "phpserialize", "javaserialize", "protobuf"];
export function isRedisValueView(value: string | null | undefined): value is RedisValueView {
return value === "utf8" || value === "ascii" || value === "binary" || value === "json" || value === "unicodejson" || value === "yaml" || value === "xml" || value === "hex" || value === "base64";
}
export function isRedisValueCodec(value: string | null | undefined): value is RedisValueCodec {
return value === "none" || value === "gzip" || value === "zlib" || value === "deflate" || value === "base64" || value === "msgpack" || value === "pickle" || value === "phpserialize" || value === "protobuf" || value === "javaserialize";
}
export function isDecompressCodec(codec: RedisValueCodec): codec is "gzip" | "zlib" | "deflate" {
return codec === "gzip" || codec === "zlib" || codec === "deflate";
}
/** Views that render a JSON-able value instead of the raw bytes. */
export function isJsonDerivedView(view: RedisValueView): boolean {
return view === "json" || view === "unicodejson" || view === "yaml" || view === "xml";
}
export function isStructuredCodec(codec: RedisValueCodec): boolean {
return codec === "pickle" || codec === "msgpack" || codec === "phpserialize" || codec === "protobuf" || codec === "javaserialize";
}
export interface RedisMemberDetail {
text: string;
@@ -19,8 +44,13 @@ export interface RedisMemberDetail {
format: RedisMemberDetailFormat;
json?: RedisJsonDetail;
javaSerialized?: RedisJavaSerializedDetail;
pickle?: RedisPickleDetail;
msgpack?: RedisMsgpackDetail;
phpSerialized?: RedisPhpSerializedDetail;
availableFormats: RedisValueFormat[];
defaultFormat: RedisValueFormat;
availableCodecs: RedisValueCodec[];
defaultCodec: RedisValueCodec;
byteCount: number;
base64Text: string;
hexRows: BinaryHexViewRow[];
@@ -92,7 +122,9 @@ export function formatRedisMemberDetail(value: unknown, options: RedisMemberDeta
if (typeof value === "string") {
const bytes = new TextEncoder().encode(value);
const json = options.allowJsonText ? (parseRedisJsonDetail(value) ?? undefined) : undefined;
const extra = detectStructuredCodecs(bytes);
const textFormats: RedisValueFormat[] = ["utf8", "ascii", "binary"];
const extraFormats: RedisValueFormat[] = json ? JSON_DERIVED_FORMATS : [];
return {
text: sanitizeRedisDisplayText(value),
rawText: value,
@@ -102,8 +134,14 @@ export function formatRedisMemberDetail(value: unknown, options: RedisMemberDeta
binaryText: binaryBytesToText(bytes),
format: "text",
json,
availableFormats: formatOrderForValue("utf8", textFormats, json ? ["json"] : []),
javaSerialized: extra.javaSerialized,
pickle: extra.pickle,
msgpack: extra.msgpack,
phpSerialized: extra.phpSerialized,
availableFormats: formatOrderForValue("utf8", textFormats, extraFormats),
defaultFormat: "utf8",
availableCodecs: REDIS_VALUE_CODEC_ORDER,
defaultCodec: extra.defaultCodec,
byteCount: bytes.byteLength,
base64Text: bytesToBase64(bytes),
hexRows: buildBinaryHexViewRows(bytes),
@@ -124,8 +162,10 @@ export function formatRedisMemberDetail(value: unknown, options: RedisMemberDeta
binaryText: binaryBytesToText(bytes),
format: "json",
json: { rawText: formattedText, formattedText, value },
availableFormats: formatOrderForValue("json", ["utf8"], ["json"]),
availableFormats: formatOrderForValue("json", ["utf8"], JSON_DERIVED_FORMATS),
defaultFormat: "json",
availableCodecs: ["none"],
defaultCodec: "none",
byteCount: bytes.byteLength,
base64Text: bytesToBase64(bytes),
hexRows: buildBinaryHexViewRows(bytes),
@@ -145,6 +185,8 @@ export function formatRedisMemberDetail(value: unknown, options: RedisMemberDeta
format: "text",
availableFormats: formatOrderForValue("utf8", ["utf8"], []),
defaultFormat: "utf8",
availableCodecs: ["none"],
defaultCodec: "none",
byteCount: bytes.byteLength,
base64Text: bytesToBase64(bytes),
hexRows: buildBinaryHexViewRows(bytes),
@@ -231,6 +273,85 @@ export function normalizeRedisJsonDraft(text: string): RedisJsonDraftNormalizati
}
}
const JSON_DERIVED_FORMATS: RedisValueFormat[] = ["json", "unicodejson", "yaml", "xml"];
export function unicodeRedisJsonText(json: RedisJsonDetail): string {
return decodeJsonUnicodeEscapes(json.formattedText);
}
export function jsonToYamlText(value: unknown): string {
return `${toYamlLines(value, 0).join("\n")}\n`;
}
export function jsonToXmlText(value: unknown): string {
return `<?xml version="1.0" encoding="UTF-8"?>\n${toXmlNodes(value, "root", 0)}`;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function yamlScalar(value: unknown): string {
if (value === null) return "null";
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "number" || typeof value === "bigint") return String(value);
// Lossless JSON numbers keep their raw spelling instead of becoming strings.
if (isLosslessJsonNumber(value)) return String(value.raw);
const text = String(value);
// Quote strings YAML would otherwise reinterpret as scalars of another type.
if (text === "" || /^(?:null|true|false|~|-?\d)/i.test(text) || /[:#{}[\],&*?|>'"%@`]/.test(text) || /\s/.test(text)) return JSON.stringify(text);
return text;
}
function toYamlLines(value: unknown, indent: number): string[] {
const pad = " ".repeat(indent);
if (Array.isArray(value)) {
if (value.length === 0) return [`${pad}[]`];
return value.flatMap((item) => {
if (isPlainObject(item) || Array.isArray(item)) {
const nested = toYamlLines(item, indent + 1);
return [`${pad}- ${nested[0].trimStart()}`, ...nested.slice(1)];
}
return [`${pad}- ${yamlScalar(item)}`];
});
}
if (isPlainObject(value)) {
const entries = Object.entries(value);
if (entries.length === 0) return [`${pad}{}`];
return entries.flatMap(([key, item]) => {
const isEmptyContainer = (isPlainObject(item) && Object.keys(item).length === 0) || (Array.isArray(item) && item.length === 0);
if (isEmptyContainer) return [`${pad}${yamlScalar(key)}: ${Array.isArray(item) ? "[]" : "{}"}`];
if (isPlainObject(item) || Array.isArray(item)) return [`${pad}${yamlScalar(key)}:`, ...toYamlLines(item, indent + 1)];
return [`${pad}${yamlScalar(key)}: ${yamlScalar(item)}`];
});
}
return [`${pad}${yamlScalar(value)}`];
}
function xmlEscape(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function xmlTag(name: string): string {
const tag = name.replace(/[^\w.-]/g, "_");
if (!tag) return "item";
return /^-|\./.test(tag) || /^\d/.test(tag) ? `_${tag}` : tag;
}
function toXmlNodes(value: unknown, name: string, indent: number): string {
const pad = " ".repeat(indent);
const tag = xmlTag(name);
if (Array.isArray(value)) return value.map((item) => toXmlNodes(item, tag === "root" ? "item" : tag, indent)).join("\n");
if (isPlainObject(value)) {
const inner = Object.entries(value)
.map(([key, item]) => toXmlNodes(item, key, indent + 1))
.join("\n");
return `${pad}<${tag}>\n${inner}\n${pad}</${tag}>`;
}
const text = value === null ? "" : xmlEscape(isLosslessJsonNumber(value) ? String(value.raw) : String(value));
return `${pad}<${tag}>${text}</${tag}>`;
}
export function preferredRedisValueFormat(value: unknown, preferred?: RedisValueFormat | null, options: RedisMemberDetailOptions = {}): RedisValueFormat {
const detail = formatRedisMemberDetail(value, options);
if (preferred && detail.availableFormats.includes(preferred) && shouldReuseRedisValueFormatPreference(detail, preferred)) return preferred;
@@ -238,14 +359,21 @@ export function preferredRedisValueFormat(value: unknown, preferred?: RedisValue
}
export function canRenderRedisValueFormat(detail: RedisMemberDetail, format: RedisValueFormat): boolean {
switch (format) {
case "json":
return Boolean(detail.json);
case "javaserialize":
return Boolean(detail.javaSerialized);
default:
return true;
}
return !isJsonDerivedView(format) || Boolean(detail.json);
}
export function canRenderRedisValueView(detail: RedisMemberDetail, view: RedisValueView): boolean {
return !isJsonDerivedView(view) || Boolean(detail.json);
}
export function preferredRedisValueView(value: unknown, preferred?: RedisValueView | null, options: RedisMemberDetailOptions = {}): RedisValueView {
const format = preferredRedisValueFormat(value, preferred, options);
return isRedisValueView(format) ? format : "utf8";
}
export function preferredRedisValueCodec(detail: RedisMemberDetail, preferred?: RedisValueCodec | null): RedisValueCodec {
if (preferred && preferred !== "none" && detail.availableCodecs.includes(preferred)) return preferred;
return "none";
}
export function redisMemberCopyText(value: unknown): string {
@@ -458,13 +586,11 @@ function formatRedisBlobDetail(blob: RedisBlob, options: RedisMemberDetailOption
const asciiText = asciiBytesToText(bytes);
const binaryText = binaryBytesToText(bytes);
const rawText = strictUtf8Text ?? binaryText;
const javaSerialized = parseJavaSerializedDetail(bytes);
const defaultFormat: RedisValueFormat = javaSerialized ? "javaserialize" : strictUtf8Text != null ? "utf8" : "hex";
const extra = detectStructuredCodecs(bytes);
const defaultFormat: RedisValueFormat = strictUtf8Text != null ? "utf8" : "hex";
const json = options.allowJsonText && strictUtf8Text != null ? (parseRedisJsonDetail(strictUtf8Text) ?? undefined) : undefined;
const textFormats: RedisValueFormat[] = strictUtf8Text != null ? ["utf8", "ascii", "binary"] : ["binary"];
const extraFormats: RedisValueFormat[] = [];
if (json) extraFormats.push("json");
if (javaSerialized) extraFormats.push("javaserialize");
const extraFormats: RedisValueFormat[] = json ? JSON_DERIVED_FORMATS : [];
return {
text: redisBlobDisplayText(blob),
rawText,
@@ -474,9 +600,14 @@ function formatRedisBlobDetail(blob: RedisBlob, options: RedisMemberDetailOption
binaryText,
format: "text",
json,
javaSerialized: javaSerialized ?? undefined,
javaSerialized: extra.javaSerialized,
pickle: extra.pickle,
msgpack: extra.msgpack,
phpSerialized: extra.phpSerialized,
availableFormats: formatOrderForValue(defaultFormat, textFormats, extraFormats),
defaultFormat,
availableCodecs: REDIS_VALUE_CODEC_ORDER,
defaultCodec: extra.defaultCodec,
byteCount: bytes.byteLength,
base64Text: blob.raw_base64,
hexRows: buildBinaryHexViewRows(bytes),
@@ -490,12 +621,28 @@ function formatRedisJsonString(value: string): string | null {
}
function formatOrderForValue(defaultFormat: RedisValueFormat, textFormats: RedisValueFormat[], extraFormats: RedisValueFormat[]): RedisValueFormat[] {
const availableFormats: RedisValueFormat[] = [...textFormats];
availableFormats.push(...extraFormats);
availableFormats.push("hex", "base64");
const availableFormats: RedisValueFormat[] = [...textFormats, ...extraFormats, "hex", "base64"];
return [defaultFormat, ...availableFormats.filter((format) => format !== defaultFormat)];
}
type DetectedCodecs = {
javaSerialized?: RedisJavaSerializedDetail;
pickle?: RedisPickleDetail;
msgpack?: RedisMsgpackDetail;
phpSerialized?: RedisPhpSerializedDetail;
defaultCodec: RedisValueCodec;
};
function detectStructuredCodecs(bytes: Uint8Array): DetectedCodecs {
const javaSerialized = parseJavaSerializedDetail(bytes) ?? undefined;
const pickle = javaSerialized || !isPickleMagic(bytes) ? undefined : (decodePickle(bytes) ?? undefined);
const msgpack = javaSerialized || pickle ? undefined : (decodeMsgpack(bytes) ?? undefined);
// Protobuf is deliberately absent: without a schema the wire format matches
// too many arbitrary byte strings, so it only decodes on explicit selection.
const phpSerialized = javaSerialized || pickle || msgpack ? undefined : (decodePhpSerialized(bytes) ?? undefined);
return { javaSerialized, pickle, msgpack, phpSerialized, defaultCodec: "none" };
}
function shouldReuseRedisValueFormatPreference(detail: RedisMemberDetail, format: RedisValueFormat): boolean {
if (!detail.editable) return true;
return format === "utf8" || format === "json";