fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735)

This commit is contained in:
Sebastian Cao authored and GitHub committed 2026-10-29 12:37:37 -05:00
1 parent 1f8b006831
commit 3173f45e7e
3 files changed
+124 -5

No files matched your search

+1
View File
@@ -13,6 +13,7 @@ Plugin API 1.13.0.
### Fixed ### Fixed
- `@termix-ssh/plugin-sdk/ui` is built in production mode, so outside Termix it no longer sends requests to the Vite dev server path - `@termix-ssh/plugin-sdk/ui` is built in production mode, so outside Termix it no longer sends requests to the Vite dev server path
- `execCommand` buffers output and decodes it once as strict UTF-8 with a GBK fallback, exposed as `decodeCommandOutput`, so messages from a Windows host on a legacy codepage are readable instead of � characters
## 1.0.21 ## 1.0.21
+35 -5
View File
@@ -21,6 +21,30 @@ export interface ExecResult {
code: number | null; code: number | null;
} }
/**
* Decodes raw command output. Strict UTF-8 first; when that fails, tries GBK,
* which is what a Chinese-locale Windows console sends (its bytes are almost
* never valid UTF-8) and keeps it only when it decodes without replacement
* characters. Anything else falls back to lossy UTF-8, the old behavior.
*/
export function decodeCommandOutput(buf: Buffer): string {
try {
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
buf,
);
} catch {
// not valid UTF-8, try the legacy codepage
}
try {
// A Node built with small-ICU has no GBK decoder and throws here.
const gbk = new TextDecoder("gbk").decode(buf);
if (!gbk.includes("�")) return gbk;
} catch {
// expected - fall through to lossy UTF-8
}
return buf.toString("utf8");
}
/** Runs one non-interactive command over an existing ssh2 client. */ /** Runs one non-interactive command over an existing ssh2 client. */
export function execCommand( export function execCommand(
client: Client, client: Client,
@@ -65,8 +89,10 @@ export function execCommand(
} }
stream = _stream; stream = _stream;
let stdout = ""; // Chunks are buffered and decoded once on close: a multi-byte character
let stderr = ""; // can be split across chunks, and charset detection needs it whole.
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let exitCode: number | null = null; let exitCode: number | null = null;
stream stream
@@ -75,11 +101,15 @@ export function execCommand(
settled = true; settled = true;
exitCode = typeof code === "number" ? code : null; exitCode = typeof code === "number" ? code : null;
cleanup(); cleanup();
resolve({ stdout, stderr, code: exitCode }); resolve({
stdout: decodeCommandOutput(Buffer.concat(stdout)),
stderr: decodeCommandOutput(Buffer.concat(stderr)),
code: exitCode,
});
} }
}) })
.on("data", (data: Buffer) => { .on("data", (data: Buffer) => {
stdout += data.toString("utf8"); stdout.push(data);
}) })
.on("error", (streamErr: Error) => { .on("error", (streamErr: Error) => {
if (!settled) { if (!settled) {
@@ -92,7 +122,7 @@ export function execCommand(
if (stream.stderr) { if (stream.stderr) {
stream.stderr stream.stderr
.on("data", (data: Buffer) => { .on("data", (data: Buffer) => {
stderr += data.toString("utf8"); stderr.push(data);
}) })
.on("error", (stderrErr: Error) => { .on("error", (stderrErr: Error) => {
if (!settled) { if (!settled) {
@@ -1,3 +1,4 @@
import { EventEmitter } from "node:events";
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { import {
parsePlatformProbe, parsePlatformProbe,
@@ -7,6 +8,7 @@ import {
buildSudoCommand, buildSudoCommand,
parseUpgradable, parseUpgradable,
execCommand, execCommand,
decodeCommandOutput,
} from "@termix-ssh/plugin-sdk/host-commands"; } from "@termix-ssh/plugin-sdk/host-commands";
describe("parsePlatformProbe", () => { describe("parsePlatformProbe", () => {
@@ -116,6 +118,32 @@ describe("parseUpgradable", () => {
}); });
}); });
class FakeStream extends EventEmitter {
stderr = new EventEmitter();
destroy() {}
}
function fakeClient(script: (stream: FakeStream) => void) {
return {
exec: (
_command: string,
_options: unknown,
callback: (err: Error | undefined, stream: FakeStream) => void,
) => {
const stream = new FakeStream();
callback(undefined, stream);
script(stream);
},
} as never;
}
// "The system cannot find the path specified." as the GBK bytes a
// Chinese-locale Windows console actually sends.
const GBK_PATH_ERROR = Buffer.from(
"cfb5cdb3d5d2b2bbb5bdd6b8b6a8b5c4c2b7beb6a1a3",
"hex",
);
describe("execCommand", () => { describe("execCommand", () => {
it("leaves the command out of the timeout error", async () => { it("leaves the command out of the timeout error", async () => {
const client = { exec: () => {} } as never; const client = { exec: () => {} } as never;
@@ -124,4 +152,64 @@ describe("execCommand", () => {
/^Command timeout after 5ms$/, /^Command timeout after 5ms$/,
); );
}); });
it("decodes GBK stderr from a localized Windows host", async () => {
const result = await execCommand(
fakeClient((stream) => {
stream.stderr.emit("data", GBK_PATH_ERROR.subarray(0, 7));
stream.stderr.emit("data", GBK_PATH_ERROR.subarray(7));
stream.emit("close", 1);
}),
"dir",
);
expect(result.stderr).toBe("系统找不到指定的路径。");
expect(result.code).toBe(1);
});
it("reassembles a UTF-8 character split across chunks", async () => {
const utf8 = Buffer.from("以太网 2", "utf8");
const result = await execCommand(
fakeClient((stream) => {
stream.emit("data", utf8.subarray(0, 4)); // cuts 太 in half
stream.emit("data", utf8.subarray(4));
stream.emit("close", 0);
}),
"ipconfig",
);
expect(result.stdout).toBe("以太网 2");
});
it("passes plain ASCII output through unchanged", async () => {
const result = await execCommand(
fakeClient((stream) => {
stream.emit("data", Buffer.from("ok\n"));
stream.stderr.emit("data", Buffer.from("warn\n"));
stream.emit("close", 0);
}),
"echo ok",
);
expect(result).toEqual({ stdout: "ok\n", stderr: "warn\n", code: 0 });
});
});
describe("decodeCommandOutput", () => {
it("keeps valid UTF-8, including the empty buffer", () => {
expect(decodeCommandOutput(Buffer.from("以太网 2", "utf8"))).toBe(
"以太网 2",
);
expect(decodeCommandOutput(Buffer.alloc(0))).toBe("");
});
it("falls back to GBK when the bytes are not valid UTF-8", () => {
// "以太网 2" in GBK, the interface name of the metrics widget issue.
expect(decodeCommandOutput(Buffer.from("d2d4ccabcdf82032", "hex"))).toBe(
"以太网 2",
);
expect(decodeCommandOutput(GBK_PATH_ERROR)).toBe("系统找不到指定的路径。");
});
it("keeps lossy UTF-8 when no decoder matches", () => {
// 0xff is a valid byte in neither UTF-8 nor GBK.
expect(decodeCommandOutput(Buffer.from([0x68, 0x69, 0xff]))).toBe("hi�");
});
}); });