fix(redis): support legacy pickle and valid XML tags
This commit is contained in:
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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4407,6 +4407,12 @@ export default withEnglishFallback({
|
||||
jsonViewDecoded: "デコード",
|
||||
rawContent: "生コンテンツ",
|
||||
wordWrap: "折り返し",
|
||||
codecNone: "なし",
|
||||
codecRowLabel: "デコード",
|
||||
viewRowLabel: "表示",
|
||||
codecBase64: "Base64 デコード",
|
||||
codecMismatch: "選択したデコード方式では復元できません。元の内容を表示しています",
|
||||
unicodeJsonView: "Unicode JSON",
|
||||
decompressedView: "解凍ビュー",
|
||||
decompressedLoading: "解凍中…",
|
||||
decompressedFailed: "選択した形式で解凍できません",
|
||||
|
||||
@@ -4026,6 +4026,12 @@ export default withEnglishFallback({
|
||||
jsonViewDecoded: "디코딩",
|
||||
rawContent: "원시 내용",
|
||||
wordWrap: "자동 줄바꿈",
|
||||
codecNone: "없음",
|
||||
codecRowLabel: "디코딩",
|
||||
viewRowLabel: "보기",
|
||||
codecBase64: "Base64 디코딩",
|
||||
codecMismatch: "선택한 디코딩 방식으로 복원할 수 없어 원본 내용을 표시합니다",
|
||||
unicodeJsonView: "Unicode JSON",
|
||||
decompressedView: "압축 해제 보기",
|
||||
decompressedLoading: "압축 해제 중…",
|
||||
decompressedFailed: "선택한 형식으로 압축을 해제할 수 없습니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4543,6 +4543,12 @@ export default withEnglishFallback({
|
||||
jsonViewDecoded: "解码",
|
||||
rawContent: "原始内容",
|
||||
wordWrap: "自动换行",
|
||||
codecNone: "无",
|
||||
codecRowLabel: "解码",
|
||||
viewRowLabel: "视图",
|
||||
codecBase64: "Base64 解码",
|
||||
unicodeJsonView: "Unicode JSON",
|
||||
codecMismatch: "无法按所选解码方式还原,已显示原始内容",
|
||||
decompressedView: "解压视图",
|
||||
decompressedLoading: "解压中…",
|
||||
decompressedFailed: "无法按所选格式解压",
|
||||
|
||||
@@ -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 < "b" & 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
-1
@@ -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);
|
||||
}
|
||||
File renamed without changes.
@@ -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";
|
||||
File renamed without changes.
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
Reference in new issue
Block a user