From 72c901c478f88700b57a41062d72789c7d0a5241 Mon Sep 17 00:00:00 2001 From: Maofeng Date: Wed, 29 Jul 2026 19:20:15 +0800 Subject: [PATCH] fix(console-ui): render binary invocation output safely - decode UTF-8 responses in fatal mode and reject control-byte payloads - fall back to hexadecimal output instead of replacement characters - show response byte count, selected representation, automatic fallback, and latency - preserve the existing counter u64 representation and cover the wiring in SSR tests --- console/src/routes/index.tsx | 86 ++++++++++++++++++++++++---- console/src/styles/app.css | 12 ++++ console/tests/rendered-html.test.mjs | 2 + 3 files changed, 88 insertions(+), 12 deletions(-) diff --git a/console/src/routes/index.tsx b/console/src/routes/index.tsx index 8d11143..15e63c5 100644 --- a/console/src/routes/index.tsx +++ b/console/src/routes/index.tsx @@ -191,16 +191,65 @@ function invocationInputSize(value: string, format: "utf8" | "hex") { } } +type FormattedInvocationOutput = { + text: string; + format: "UTF-8" | "HEX" | "U64 LE"; + automatic: boolean; +}; + +function formatHex(bytes: Uint8Array) { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" "); +} + +function decodeReadableUtf8(bytes: Uint8Array) { + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return null; + } + + for (const character of text) { + const codePoint = character.codePointAt(0) ?? 0; + const allowedWhitespace = codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d; + if ((codePoint < 0x20 && !allowedWhitespace) || codePoint === 0x7f) { + return null; + } + } + return text; +} + function formatInvocationOutput(service: Service, output: Uint8Array, format: "utf8" | "hex") { if (service.id === "counter" && output.byteLength === 8) { - return new DataView(output.buffer, output.byteOffset, output.byteLength) - .getBigUint64(0, true) - .toString(); + return { + text: new DataView(output.buffer, output.byteOffset, output.byteLength) + .getBigUint64(0, true) + .toString(), + format: "U64 LE", + automatic: false, + } satisfies FormattedInvocationOutput; } if (format === "hex") { - return Array.from(output, (byte) => byte.toString(16).padStart(2, "0")).join(" "); + return { + text: formatHex(output) || "(empty)", + format: "HEX", + automatic: false, + } satisfies FormattedInvocationOutput; } - return new TextDecoder().decode(output) || "(empty)"; + + const text = decodeReadableUtf8(output); + if (text !== null) { + return { + text: text || "(empty)", + format: "UTF-8", + automatic: false, + } satisfies FormattedInvocationOutput; + } + return { + text: formatHex(output), + format: "HEX", + automatic: true, + } satisfies FormattedInvocationOutput; } export const Route = createFileRoute("/")({ @@ -1841,6 +1890,8 @@ function InvokeDialog({ const [input, setInput] = useState(service.id === "echo" ? "hello wasm" : ""); const [format, setFormat] = useState<"utf8" | "hex">("utf8"); const [output, setOutput] = useState(""); + const [outputBytes, setOutputBytes] = useState(null); + const [outputFormat, setOutputFormat] = useState(null); const [running, setRunning] = useState(false); const [failed, setFailed] = useState(false); const [latency, setLatency] = useState(null); @@ -1848,11 +1899,16 @@ function InvokeDialog({ async function invoke() { setRunning(true); setOutput(""); + setOutputBytes(null); + setOutputFormat(null); setFailed(false); setLatency(null); try { const result = await onInvoke(service, parseInvocationInput(input, format)); - setOutput(formatInvocationOutput(service, result.output, format)); + const formatted = formatInvocationOutput(service, result.output, format); + setOutput(formatted.text); + setOutputBytes(result.outputBytes); + setOutputFormat(formatted); setLatency(result.latencyMs); } catch (invokeError) { setFailed(true); @@ -1871,7 +1927,7 @@ function InvokeDialog({ >
-
+