From 3f6ebd2de24da649bba2b64900d59257e050e9b0 Mon Sep 17 00:00:00 2001 From: Krasnov Dmitry Date: Wed, 15 Jul 2026 16:38:00 +0300 Subject: [PATCH 1/3] fix: download raw bytes for binary topic messages instead of lossy UTF-8 text Topic message download reused the UTF-8-decoded preview string for the Download button. For non-UTF-8 binary payloads this silently corrupted the data (invalid byte sequences replaced with U+FFFD), so the downloaded file no longer matched the original message bytes. Download now uses the raw decoded Uint8Array directly; the preview string is still used for JSON/text rendering and clipboard copy. Fixes #4129 --- .../components/TopicMessage.tsx | 15 +++++++++++---- src/utils/downloadFile.ts | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/components/TopicMessage.tsx b/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/components/TopicMessage.tsx index 8e3b0188c3..9ade40beb8 100644 --- a/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/components/TopicMessage.tsx +++ b/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/components/TopicMessage.tsx @@ -45,7 +45,7 @@ export function TopicMessage({ // (error-only rows). In that case we render the alerts but no content/toolbar. const hasMessage = !isNil(message); - const {preparedMessage, decodedMessage, isJson} = React.useMemo(() => { + const {preparedMessage, decodedMessage, isJson, rawBytes} = React.useMemo(() => { // A schematized value is already a parsed JSON value (object/array or a // primitive such as a string) and must be used as-is. Only legacy, // non-schematized string values are base64-encoded and need decoding. @@ -53,9 +53,15 @@ export function TopicMessage({ // `isSchematized` signal derived from the response schema context rather // than guessing from the type. let preparedMessage: string | unknown = message; - // decodedMessage is always a string, suitable for download/clipboard. + // decodedMessage is always a string, suitable for preview/clipboard. It is + // a best-effort UTF-8 rendering and MUST NOT be used for downloading the + // message, since non-UTF-8 binary payloads get lossily mangled (invalid + // byte sequences become U+FFFD) - use `rawBytes` for that instead. let decodedMessage: string = typeof message === 'string' ? message : JSON.stringify(message, null, 2); + // Raw decoded bytes of a non-schematized base64 message, exactly as sent + // by the backend - the only safe source for downloading binary messages. + let rawBytes: Uint8Array | undefined; if (typeof message === 'string' && !isSchematized) { try { @@ -64,6 +70,7 @@ export function TopicMessage({ for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } + rawBytes = bytes; decodedMessage = utf8Decoder.decode(bytes); } catch (e) { console.warn(e); @@ -89,7 +96,7 @@ export function TopicMessage({ preparedMessage = JSON.stringify(preparedMessage, null, 2); } - return {preparedMessage, decodedMessage, isJson}; + return {preparedMessage, decodedMessage, isJson, rawBytes}; }, [message, size, isSchematized]); const renderMessageContent = () => { @@ -135,7 +142,7 @@ export function TopicMessage({ onClick={(e) => { e.stopPropagation(); createAndDownloadFile( - decodedMessage, + rawBytes ?? decodedMessage, `topic-message-${offset ?? 'unknown-offset'}`, ); }} diff --git a/src/utils/downloadFile.ts b/src/utils/downloadFile.ts index a80b2ad9b9..080eaf72b8 100644 --- a/src/utils/downloadFile.ts +++ b/src/utils/downloadFile.ts @@ -7,7 +7,7 @@ export function downloadFile(url: string, filename: string) { document.body.removeChild(link); } -export const createAndDownloadFile = (data: string, fileName: string, type?: string) => { +export const createAndDownloadFile = (data: BlobPart, fileName: string, type?: string) => { const blob = new Blob([data], { type, }); From 486cda798f382271104d6b54fc6e7ef44dea1db7 Mon Sep 17 00:00:00 2001 From: Krasnov Dmitry Date: Tue, 21 Jul 2026 15:10:54 +0300 Subject: [PATCH 2/3] test: add regression coverage for binary topic message download Adds a component test asserting the Download button receives the raw Uint8Array bytes for non-UTF-8 payloads (and unchanged bytes for plain UTF-8 text), so reverting to the lossy decodedMessage string would fail the suite. Also gives the Download button an explicit aria-label (matching how ClipboardButton already labels itself) so the test - and screen readers - can target it without relying on the hover/focus-only tooltip, and polyfills the Encoding API in jsdom test setup, which TopicMessage needs at module load time. Addresses review feedback on #4130. --- .../components/TopicMessage.tsx | 1 + .../components/__test__/TopicMessage.test.tsx | 54 +++++++++++++++++++ src/setupTests.js | 10 ++++ 3 files changed, 65 insertions(+) create mode 100644 src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/components/__test__/TopicMessage.test.tsx diff --git a/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/components/TopicMessage.tsx b/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/components/TopicMessage.tsx index 9ade40beb8..62ab3c4358 100644 --- a/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/components/TopicMessage.tsx +++ b/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/components/TopicMessage.tsx @@ -139,6 +139,7 @@ export function TopicMessage({