feat(redis): add decompressed view for compressed values

This commit is contained in:
Abeautifulsnow 2026-08-03 20:59:39 +08:00 committed by GitHub
parent 06838c422b
commit ec44e09ec8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 551 additions and 8 deletions

View File

@ -4,7 +4,7 @@ import type { CalendarDateTime } from "@internationalized/date";
import { useI18n } from "vue-i18n";
import { onClickOutside } from "@vueuse/core";
import { DynamicScroller, DynamicScrollerItem, RecycleScroller } from "vue-virtual-scroller";
import { Check, ChevronDown, Copy, ClipboardCopy, Eye, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, ArrowUp, ArrowDown, ArrowUpDown, Search } from "@lucide/vue";
import { Check, ChevronDown, Copy, ClipboardCopy, Eye, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, ArrowUp, ArrowDown, ArrowUpDown, Search, FileArchive } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@ -29,9 +29,11 @@ import { computeDisplayTtl, computeTtlCountdownTick, computeTtlCountdownValue, c
import {
canRenderRedisValueFormat,
canEditRedisMemberDetail,
decodeRedisBlob,
formatRedisMemberDetail,
getRedisMemberSelectionKey,
isRedisBlob,
parseRedisJsonDetail,
preferredRedisValueFormat,
REDIS_VALUE_FORMAT_DISPLAY_ORDER,
redisBlobText,
@ -47,6 +49,7 @@ import {
type RedisCollectionItem,
type RedisValueFormat,
} from "@/lib/redis/redisValuePresentation";
import { decompressRedisValue, isGzipMagic, type RedisDecompressAlgorithm } from "@/lib/redis/redisCompression";
import { canFullHighlightRedisText, findRedisTextMatches, nextRedisSearchMatchIndex, REDIS_VALUE_SEARCH_MATCH_LIMIT, renderRedisTextSearchHtml, redisValueSearchStatus } from "@/lib/redis/redisValueSearch";
import TextContentSearchBar from "@/components/common/TextContentSearchBar.vue";
import { formatJsonSource } from "@/lib/common/safeJsonFormat";
@ -164,6 +167,70 @@ const memberValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
const redisJsonWordWrap = ref(readRedisJsonWordWrap());
const redisJsonHighlighter = ref<JsonHighlighter>();
// Decompressed view state. Decompression yields to the event loop so the
// loading state paints before the synchronous bounded inflate runs; the result
// intentionally lives outside the synchronous format pipeline built by
// formatRedisMemberDetail, and the request id guards against stale results when
// the user switches values or formats mid-flight.
type RedisDecompressedState = { status: "idle" } | { status: "loading" } | { status: "success"; text: string; algorithm: RedisDecompressAlgorithm } | { status: "error"; reason: "corrupt" | "limit" };
const decompressedState = ref<RedisDecompressedState>({ status: "idle" });
let decompressRequestId = 0;
async function runDecompress(bytes: Uint8Array, algorithm?: RedisDecompressAlgorithm) {
const requestId = ++decompressRequestId;
decompressedState.value = { status: "loading" };
const result = await decompressRedisValue(bytes, algorithm ? { algorithm } : {});
if (requestId !== decompressRequestId) return;
if (result.ok) decompressedState.value = { status: "success", text: result.text, algorithm: result.algorithm };
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. */
function currentDecompressTargetBytes(): Uint8Array | null {
if (stringValueView.value === "decompressed") {
const blob = stringBlob.value;
return blob ? decodeRedisBlob(blob) : null;
}
if (memberValueView.value === "decompressed") {
const raw = selectedMemberRaw.value;
if (raw == null) return null;
// Plain-string members (not blobs) still attempt decompression so the user
// gets the non-blocking "not compressed" notice instead of silence.
return isRedisBlob(raw) ? decodeRedisBlob(raw) : new TextEncoder().encode(typeof raw === "string" ? raw : formatRedisMemberDetail(raw).rawText);
}
return null;
}
function refreshDecompressedView(algorithm?: RedisDecompressAlgorithm) {
const bytes = currentDecompressTargetBytes();
if (bytes) void runDecompress(bytes, algorithm);
else decompressedState.value = { status: "idle" };
}
/** Last-resort explicit decode for values that are raw RFC 1951 DEFLATE (never auto-detected). */
function retryDecompressAsDeflate() {
refreshDecompressedView("deflate");
}
const decompressedJsonDetail = computed(() => {
const state = decompressedState.value;
return state.status === "success" ? parseRedisJsonDetail(state.text) : null;
});
const decompressedFailureMessage = computed(() => {
const state = decompressedState.value;
if (state.status !== "error") return "";
if (state.reason === "limit") return t("redis.decompressedLimitExceeded");
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.
@ -333,6 +400,42 @@ 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();
});
watch([memberValueView, selectedMemberRaw], ([view]) => {
if (view !== "decompressed") return;
refreshDecompressedView();
});
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. */
const decompressedRawFallbackText = computed(() => {
if (stringValueView.value === "decompressed" && stringValueDetail.value) {
return detailTextForFormat(stringValueDetail.value, stringValueDetail.value.defaultFormat);
}
if (memberValueView.value === "decompressed" && 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);
});
const redisJsonAppearance = computed(() => (isDark.value ? "dark" : "light"));
const isBinaryStringValue = computed(() => Boolean(stringValueDetail.value?.binary));
const selectedMemberCanEdit = computed(() => selectedMemberContext.value?.canEdit ?? false);
@ -462,10 +565,18 @@ 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);
}
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 (stringValueView.value === "json" && stringValueDetail.value.json) return editValue.value;
if (stringValueView.value === "utf8" && canEditCurrentStringFormat.value) return editValue.value;
return detailTextForFormat(stringValueDetail.value, stringValueView.value);
@ -946,7 +1057,8 @@ 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";
}
rememberRedisValueFormat(format);
// Decompressed is a per-value view, never a persisted preference.
if (format !== "decompressed") rememberRedisValueFormat(format);
}
}
@ -968,7 +1080,8 @@ function setMemberValueFormat(format: RedisValueFormat) {
memberDraftFormat.value = "utf8";
}
}
rememberRedisValueFormat(format);
// Decompressed is a per-value view, never a persisted preference.
if (format !== "decompressed") rememberRedisValueFormat(format);
}
}
@ -988,13 +1101,15 @@ function redisFormatLabel(format: RedisValueFormat, rawLabel?: string): string {
return t("grid.hexViewerHex");
case "base64":
return "Base64";
case "decompressed":
return decompressedLabel.value;
default:
return rawLabel || t("redis.rawContent");
}
}
function isTextRedisFormat(format: RedisValueFormat): boolean {
return format === "utf8" || format === "ascii" || format === "binary" || format === "json";
return format === "utf8" || format === "ascii" || format === "binary" || format === "json" || format === "decompressed";
}
function highlightRedisJson(json: string): string {
@ -1216,6 +1331,14 @@ function requestDeleteKey() {
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;
}
}
const value = data.value.data.kind === "stream" ? { ...data.value, data: { ...data.value.data, entries: streamEntries.value } } : data.value;
const text = redisValueCopyText(value, collectionItems.value);
try {
@ -2115,6 +2238,10 @@ defineExpose({ focusSearch });
{{ 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" />
<label v-if="isTextRedisFormat(stringValueView)" class="flex items-center gap-1.5 text-muted-foreground">
<WrapText class="h-3.5 w-3.5" />
@ -2147,6 +2274,35 @@ 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-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">
<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>
</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>
<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
v-if="decompressedState.status === 'error' && decompressedState.reason !== 'limit'"
variant="outline"
size="sm"
class="h-6 shrink-0 rounded-[5px] px-2 text-xs"
:title="t('redis.decompressedRetryAsDeflate')"
:aria-label="t('redis.decompressedRetryAsDeflate')"
@click="retryDecompressAsDeflate"
>
{{ t("redis.decompressedRetryAsDeflate") }}
</Button>
</div>
</template>
</div>
<textarea
v-else-if="stringValueView === 'utf8' && canEditCurrentStringFormat"
ref="stringTextareaRef"
@ -2706,6 +2862,10 @@ 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')">
<FileArchive class="h-3 w-3 mr-1" />
Gzip
</Badge>
</DialogTitle>
</DialogHeader>
<template v-if="isEditingMember">
@ -2752,6 +2912,35 @@ 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-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">
<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>
</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>
<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
v-if="decompressedState.status === 'error' && decompressedState.reason !== 'limit'"
variant="outline"
size="sm"
class="h-6 shrink-0 rounded-[5px] px-2 text-xs"
:title="t('redis.decompressedRetryAsDeflate')"
:aria-label="t('redis.decompressedRetryAsDeflate')"
@click="retryDecompressAsDeflate"
>
{{ t("redis.decompressedRetryAsDeflate") }}
</Button>
</div>
</template>
</div>
<pre v-else-if="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" :class="detailTextClass(memberValueView)" v-html="contentSearchHighlightedHtml" />
<pre v-else 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(memberValueView)">{{ detailTextForFormat(selectedMemberDetail, memberValueView) }}</pre>
</template>
@ -2780,7 +2969,7 @@ defineExpose({ focusSearch });
<Pencil class="h-4 w-4" />
{{ t("redis.editMember") }}
</Button>
<Button variant="outline" @click="copyText(redisClipboardSafeText(detailTextForFormat(selectedMemberDetail, memberValueView)))">
<Button variant="outline" @click="copyText(redisClipboardSafeText(memberCopyText))">
<Copy class="h-4 w-4" />
{{ t("redis.copyMember") }}
</Button>

View File

@ -3598,6 +3598,12 @@ export default {
jsonView: "JSON view",
rawContent: "Raw content",
wordWrap: "Word wrap",
decompressedView: "Decompressed",
decompressedLoading: "Decompressing…",
decompressedFailed: "Unable to decompress with the selected format",
decompressedRetryAsDeflate: "Retry as DEFLATE",
decompressedLimitExceeded: "Decompressed result exceeds the 50 MiB limit",
gzipBadgeTitle: "Gzip compressed — click to view decompressed content",
formatJson: "Format",
compressJson: "Compress",
jsonFormatError: "Invalid JSON format",

View File

@ -3447,6 +3447,12 @@ export default withEnglishFallback({
jsonView: "Vista JSON",
rawContent: "Contenido original",
wordWrap: "Ajuste de línea",
decompressedView: "Descomprimido",
decompressedLoading: "Descomprimiendo…",
decompressedFailed: "No se puede descomprimir con el formato seleccionado",
decompressedRetryAsDeflate: "Reintentar como DEFLATE",
decompressedLimitExceeded: "El resultado descomprimido supera el límite de 50 MiB",
gzipBadgeTitle: "Comprimido con Gzip: haga clic para ver el contenido descomprimido",
clearHistory: "Borrar historial de comandos",
historyCleared: "Historial de comandos Redis borrado",
blockedCommand: "El comando {command} está bloqueado por seguridad. Desactiva el icono de escudo en la barra de herramientas para permitirlo.",

View File

@ -3447,6 +3447,12 @@ export default withEnglishFallback({
jsonView: "Visualizzazione JSON",
rawContent: "Contenuto grezzo",
wordWrap: "A capo automatico",
decompressedView: "Decompresso",
decompressedLoading: "Decompressione in corso…",
decompressedFailed: "Impossibile decomprimere con il formato selezionato",
decompressedRetryAsDeflate: "Riprova come DEFLATE",
decompressedLimitExceeded: "Il risultato decompresso supera il limite di 50 MiB",
gzipBadgeTitle: "Compresso con Gzip: fare clic per visualizzare il contenuto decompresso",
formatJson: "Formatta",
compressJson: "Comprimi",
jsonFormatError: "Formato JSON non valido",

View File

@ -3479,6 +3479,12 @@ export default withEnglishFallback({
jsonView: "JSONビュー",
rawContent: "生コンテンツ",
wordWrap: "折り返し",
decompressedView: "解凍ビュー",
decompressedLoading: "解凍中…",
decompressedFailed: "選択した形式で解凍できません",
decompressedRetryAsDeflate: "Deflate で再試行",
decompressedLimitExceeded: "解凍結果が 50 MiB の上限を超えています",
gzipBadgeTitle: "Gzip 圧縮 — クリックで解凍内容を表示",
formatJson: "フォーマット",
compressJson: "圧縮",
jsonFormatError: "不正なJSON形式です",

View File

@ -3212,6 +3212,12 @@ export default withEnglishFallback({
jsonView: "JSON 보기",
rawContent: "원시 내용",
wordWrap: "자동 줄바꿈",
decompressedView: "압축 해제 보기",
decompressedLoading: "압축 해제 중…",
decompressedFailed: "선택한 형식으로 압축을 해제할 수 없습니다",
decompressedRetryAsDeflate: "Deflate로 재시도",
decompressedLimitExceeded: "압축 해제 결과가 50 MiB 제한을 초과합니다",
gzipBadgeTitle: "Gzip 압축 — 클릭하여 압축 해제 내용 보기",
formatJson: "서식",
compressJson: "압축",
jsonFormatError: "잘못된 JSON 형식",

View File

@ -3449,6 +3449,12 @@ export default withEnglishFallback({
jsonView: "Visão JSON",
rawContent: "Conteúdo bruto",
wordWrap: "Quebra de linha",
decompressedView: "Descomprimido",
decompressedLoading: "Descomprimindo…",
decompressedFailed: "Não foi possível descomprimir com o formato selecionado",
decompressedRetryAsDeflate: "Tentar novamente como DEFLATE",
decompressedLimitExceeded: "O resultado descomprimido excede o limite de 50 MiB",
gzipBadgeTitle: "Comprimido com Gzip — clique para ver o conteúdo descomprimido",
formatJson: "Formatar",
compressJson: "Comprimir",
jsonFormatError: "Formato JSON inválido",

View File

@ -3598,6 +3598,12 @@ export default withEnglishFallback({
jsonView: "JSON 视图",
rawContent: "原始内容",
wordWrap: "自动换行",
decompressedView: "解压视图",
decompressedLoading: "解压中…",
decompressedFailed: "无法按所选格式解压",
decompressedRetryAsDeflate: "以 Deflate 重试",
decompressedLimitExceeded: "解压结果超过 50 MiB 限制",
gzipBadgeTitle: "Gzip 压缩 — 点击查看解压内容",
formatJson: "格式化",
compressJson: "压缩",
jsonFormatError: "JSON 格式不合法",

View File

@ -2917,6 +2917,12 @@ export default withEnglishFallback({
jsonView: "JSON 檢視",
rawContent: "原始內容",
wordWrap: "自動換行",
decompressedView: "解壓檢視",
decompressedLoading: "解壓中…",
decompressedFailed: "無法按所選格式解壓",
decompressedRetryAsDeflate: "以 Deflate 重試",
decompressedLimitExceeded: "解壓結果超過 50 MiB 限制",
gzipBadgeTitle: "Gzip 壓縮 — 點擊檢視解壓內容",
formatJson: "格式化",
compressJson: "壓縮",
jsonFormatError: "無效的 JSON 格式",

View File

@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import { deflateRawSync, deflateSync, gzipSync } from "zlib";
import { decompressRedisValue, isGzipMagic, REDIS_DECOMPRESS_MAX_OUTPUT_BYTES } from "../redisCompression";
// 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
// Tauri WebView2 / WKWebView renderer — no DecompressionStream mock, no
// environment-specific behavior to paper over. node:zlib is used only to build
// fixtures (it is byte-compatible with pako for gzip/zlib/raw DEFLATE).
function gzipOf(text: string): Uint8Array {
return new Uint8Array(gzipSync(text));
}
function zlibOf(text: string): Uint8Array {
return new Uint8Array(deflateSync(text));
}
function deflateRawOf(data: Uint8Array | string): Uint8Array {
return new Uint8Array(deflateRawSync(data));
}
describe("isGzipMagic", () => {
it("detects the gzip header", () => {
expect(isGzipMagic(gzipOf("hello"))).toBe(true);
});
it("rejects non-gzip payloads", () => {
expect(isGzipMagic(new Uint8Array([0x78, 0x9c, 0x01, 0x00]))).toBe(false);
expect(isGzipMagic(new Uint8Array(0))).toBe(false);
expect(isGzipMagic(new Uint8Array([0x1f]))).toBe(false);
});
});
describe("decompressRedisValue", () => {
it("decompresses gzip via magic detection", async () => {
const result = await decompressRedisValue(gzipOf('{"a":1}'));
expect(result).toEqual({ ok: true, text: '{"a":1}', algorithm: "gzip" });
});
it("decompresses zlib-wrapped deflate", async () => {
const result = await decompressRedisValue(zlibOf("hello world"));
expect(result).toEqual({ ok: true, text: "hello world", algorithm: "zlib" });
});
it("does not auto-detect raw deflate from a valid raw-deflate stream", async () => {
// A valid raw DEFLATE stream has no framing and no checksum, so arbitrary
// binary can look like it. Auto-detection must not accept it — regression
// for the old behavior that fell back to raw deflate after zlib failed and
// displayed the "decompressed" garbage as real content.
const rawDeflate = deflateRawOf("raw deflate payload");
const result = await decompressRedisValue(rawDeflate);
expect(result).toEqual({ ok: false, reason: "corrupt" });
});
it("rejects arbitrary binary that happens to be a valid raw-deflate stream", async () => {
// This is the exact false-positive the reviewer flagged: a value that was
// never meant to be compressed data (a binary payload — a PNG header plus
// non-UTF-8 bytes) gets raw-deflated, producing a structurally valid
// bitstream. Under the old code it was accepted and shown as decompressed
// garbage; auto-detection must now reject it.
const binaryPayload = new Uint8Array([
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a, // PNG signature
0x00,
0x00,
0x00,
0x0d,
0x49,
0x48,
0x44,
0x52,
0xff,
0x00,
0xff,
0x00,
0x01,
0x02,
0x03,
0x7f,
0x80,
0xc0,
0xe0,
0xfe,
]);
const rawDeflate = deflateRawOf(binaryPayload);
const result = await decompressRedisValue(rawDeflate);
expect(result.ok).toBe(false);
});
it("decompresses raw deflate only when the algorithm is explicitly requested", async () => {
const rawDeflate = deflateRawOf("explicit raw deflate payload");
expect(await decompressRedisValue(rawDeflate)).toEqual({ ok: false, reason: "corrupt" });
const result = await decompressRedisValue(rawDeflate, { algorithm: "deflate" });
expect(result).toEqual({ ok: true, text: "explicit raw deflate payload", algorithm: "deflate" });
});
it("forces a single algorithm when requested, bypassing magic detection", async () => {
const gz = gzipOf("gzip data");
const zl = zlibOf("zlib data");
// A gzip value forced as zlib must fail, and vice versa — no cross-format
// guessing when the caller commits to an algorithm.
expect(await decompressRedisValue(gz, { algorithm: "zlib" })).toEqual({ ok: false, reason: "corrupt" });
expect(await decompressRedisValue(zl, { algorithm: "gzip" })).toEqual({ ok: false, reason: "corrupt" });
expect(await decompressRedisValue(gz, { algorithm: "gzip" })).toEqual({ ok: true, text: "gzip data", algorithm: "gzip" });
expect(await decompressRedisValue(zl, { algorithm: "zlib" })).toEqual({ ok: true, text: "zlib data", algorithm: "zlib" });
});
it("reports corrupt data without throwing", async () => {
const result = await decompressRedisValue(new Uint8Array([0x1f, 0x8b, 0x00, 0x00, 0x00]));
expect(result).toEqual({ ok: false, reason: "corrupt" });
});
it("reports corrupt for plain text that is not compressed", async () => {
const result = await decompressRedisValue(new TextEncoder().encode("just plain text"));
expect(result.ok).toBe(false);
});
it("reports corrupt for empty input", async () => {
const result = await decompressRedisValue(new Uint8Array(0));
expect(result).toEqual({ ok: false, reason: "corrupt" });
});
it("enforces the output cap during decompression for high-ratio input", async () => {
// 64 MiB of zeros compresses to ~64 KiB (~1000:1). The bounded inflate
// streams output in chunks and aborts as soon as cumulative output exceeds
// the cap — the 64 MiB is never materialized. Runs against real pako, so it
// reflects actual platform behavior rather than a mock that buffers the
// whole result first.
const payload = new Uint8Array(64 * 1024 * 1024);
const cases: Array<[Uint8Array, Parameters<typeof decompressRedisValue>[1]]> = [
[new Uint8Array(gzipSync(payload)), undefined],
[new Uint8Array(deflateSync(payload)), undefined],
[deflateRawOf(payload), { algorithm: "deflate" }],
];
for (const [compressed, options] of cases) {
const result = await decompressRedisValue(compressed, { ...options, maxOutputBytes: 1024 });
expect(result).toEqual({ ok: false, reason: "limit" });
}
});
it("allows output up to exactly the cap", async () => {
const payload = new Uint8Array(1024);
const result = await decompressRedisValue(new Uint8Array(gzipSync(payload)), { maxOutputBytes: 1024 });
expect(result.ok).toBe(true);
if (result.ok) expect(result.algorithm).toBe("gzip");
});
it("respects the shared default cap constant", () => {
expect(REDIS_DECOMPRESS_MAX_OUTPUT_BYTES).toBe(50 * 1024 * 1024);
});
it("honors a custom cap below the default", async () => {
const result = await decompressRedisValue(gzipOf("small payload"), { maxOutputBytes: 8 });
expect(result).toEqual({ ok: false, reason: "limit" });
});
});

View File

@ -0,0 +1,134 @@
import { Inflate as PakoInflate, Z_OK } from "pako";
export const REDIS_DECOMPRESS_MAX_OUTPUT_BYTES = 50 * 1024 * 1024;
export type RedisDecompressAlgorithm = "gzip" | "zlib" | "deflate";
/** Reliable gzip header — the only compression signature we trust for auto-detection. */
export function isGzipMagic(bytes: Uint8Array): boolean {
return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
}
export type RedisDecompressResult =
| {
ok: true;
text: string;
algorithm: RedisDecompressAlgorithm;
}
| {
ok: false;
reason: "corrupt" | "limit";
};
export type RedisDecompressOptions = {
maxOutputBytes?: number;
/**
* Force a specific compression format instead of auto-detecting.
* Auto-detection recognizes only gzip (magic bytes) and zlib (RFC 1950 header
* + ADLER32 checksum). Raw deflate (`"deflate"`, RFC 1951) has no framing or
* checksum, so arbitrary binary data can be misread as valid output pass
* this only when the value is known to be raw DEFLATE.
*/
algorithm?: RedisDecompressAlgorithm;
};
class DecompressionLimitError extends Error {
readonly limitBytes: number;
constructor(limitBytes: number) {
super(`Decompressed output exceeds ${limitBytes} bytes`);
this.name = "DecompressionLimitError";
this.limitBytes = limitBytes;
}
}
// pako windowBits per format: gzip = 31 (RFC 1952), zlib = 15 (RFC 1950),
// raw deflate = -15 (RFC 1951).
const WINDOW_BITS: Record<RedisDecompressAlgorithm, number> = {
gzip: 31,
zlib: 15,
deflate: -15,
};
/**
* Bounded inflate via pako's streaming `Inflate`. Output arrives chunk by chunk
* through `onData`; we count it and throw the moment cumulative output exceeds
* `maxOutputBytes`. Because pako emits chunks incrementally (its zlib strm uses
* a fixed-size output buffer), a zip bomb aborts after the first chunks past
* the cap the full output is never materialized, so peak memory stays
* ~cap + chunk + input instead of unbounded. This is an allocation-time limit,
* unlike counting bytes only after a decompressor has already buffered the
* whole result internally.
*/
function inflateBounded(bytes: Uint8Array, algorithm: RedisDecompressAlgorithm, maxOutputBytes: number): Uint8Array {
const inflater = new PakoInflate({ windowBits: WINDOW_BITS[algorithm] });
const chunks: Uint8Array[] = [];
let total = 0;
inflater.onData = (chunk) => {
total += chunk.byteLength;
if (total > maxOutputBytes) throw new DecompressionLimitError(maxOutputBytes);
chunks.push(chunk);
};
inflater.push(bytes, true);
if (inflater.err !== Z_OK) {
throw new Error(inflater.msg || `decompression failed (status ${inflater.err})`);
}
return concatBytes(chunks);
}
function concatBytes(chunks: Uint8Array[]): Uint8Array {
let total = 0;
for (const chunk of chunks) total += chunk.byteLength;
const merged = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.byteLength;
}
return merged;
}
function decodeText(bytes: Uint8Array): string {
return new TextDecoder("utf-8").decode(bytes);
}
/**
* Decompress Redis value bytes with the standard web formats.
*
* Detection order (when no `algorithm` is forced):
* 1. gzip reliable magic (`1f 8b`); a failure here means corrupt data.
* 2. zlib RFC 1950 header + ADLER32 trailer validate on success, so a
* successful zlib decode is checksum-verified.
* Raw deflate (RFC 1951) is NEVER auto-detected: it has no framing or checksum,
* so arbitrary binary can be accepted and shown, searched, or copied as
* "decompressed" content. Callers that know a value is raw DEFLATE must pass
* `{ algorithm: "deflate" }` explicitly.
*
* The `ok` result carries the algorithm that actually succeeded so the UI can
* label it (e.g. "Decompressed (zlib)").
*/
export async function decompressRedisValue(bytes: Uint8Array, options: RedisDecompressOptions = {}): Promise<RedisDecompressResult> {
const maxOutputBytes = options.maxOutputBytes ?? REDIS_DECOMPRESS_MAX_OUTPUT_BYTES;
if (bytes.length === 0) return { ok: false, reason: "corrupt" };
if (options.algorithm) {
return decompressOne(bytes, options.algorithm, maxOutputBytes);
}
if (isGzipMagic(bytes)) {
return decompressOne(bytes, "gzip", maxOutputBytes);
}
return decompressOne(bytes, "zlib", maxOutputBytes);
}
async function decompressOne(bytes: Uint8Array, algorithm: RedisDecompressAlgorithm, maxOutputBytes: number): Promise<RedisDecompressResult> {
// Yield so the UI can paint the loading state before the synchronous inflate.
await new Promise((resolve) => setTimeout(resolve, 0));
try {
const output = inflateBounded(bytes, algorithm, maxOutputBytes);
return { ok: true, text: decodeText(output), algorithm };
} catch (error) {
if (error instanceof DecompressionLimitError) return { ok: false, reason: "limit" };
return { ok: false, reason: "corrupt" };
}
}

View File

@ -4,10 +4,10 @@ import { formatJsonSource, parseJsonPreservingLargeNumbers } from "@/lib/common/
import type { RedisBlob, RedisCollectionPage, RedisHashItem, RedisListItem, RedisSetItem, RedisValue, RedisZsetItem } from "@/lib/backend/api";
import { parseJavaSerializedDetail, type RedisJavaSerializedDetail } from "@/lib/redis/javaSerialized";
export type RedisValueFormat = "utf8" | "ascii" | "binary" | "json" | "javaserialize" | "hex" | "base64";
export type RedisValueFormat = "utf8" | "ascii" | "binary" | "json" | "javaserialize" | "hex" | "base64" | "decompressed";
export type RedisMemberDetailFormat = "json" | "text";
export const REDIS_VALUE_FORMAT_DISPLAY_ORDER: RedisValueFormat[] = ["utf8", "ascii", "binary", "json", "javaserialize", "hex", "base64"];
export const REDIS_VALUE_FORMAT_DISPLAY_ORDER: RedisValueFormat[] = ["utf8", "ascii", "binary", "json", "javaserialize", "hex", "base64", "decompressed"];
export interface RedisMemberDetail {
text: string;

View File

@ -197,7 +197,7 @@
fetcherVersion = 4;
# Update with the hash reported by a failed fixed-output build:
# nix build .#dbx-pnpm-deps 2>&1 | grep 'got:'
hash = "sha256-NKYI5zHV7rnFhK2Elm2On6ffRJty4BbJcYaW9gRqPa8=";
hash = "sha256-h4KlJml4J7nIyPQRcpSJ1b3buwt81B3gIaL5Ekj38xo=";
};
# ── Step 2: vendor Cargo dependencies ───────────────────────────── #

View File

@ -89,6 +89,7 @@
"elkjs": "^0.11.1",
"leaflet": "^1.9.4",
"marked": "^18.0.4",
"pako": "^3.0.1",
"pinia": "^3.0.0",
"proj4": "^2.20.9",
"reka-ui": "^2.10.1",

View File

@ -155,6 +155,9 @@ importers:
marked:
specifier: ^18.0.4
version: 18.0.4
pako:
specifier: ^3.0.1
version: 3.0.1
pinia:
specifier: ^3.0.0
version: 3.0.4(typescript@6.0.3)(vue@3.5.35(typescript@6.0.3))
@ -3170,6 +3173,9 @@ packages:
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
pako@3.0.1:
resolution: {integrity: sha512-GupotUUI0mlhugKjUs4bjOwLt3nrehy9Ys2dxC0GtgVef5cnKggkDMmf2bq2poCCuVXopWPmqsc9VDT2iJUy+w==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@ -6735,6 +6741,8 @@ snapshots:
package-json-from-dist@1.0.1: {}
pako@3.0.1: {}
parseurl@1.3.3: {}
path-browserify@1.0.1: {}