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,3 +1,4 @@
import { EventEmitter } from "node:events";
import { describe, it, expect } from "vitest";
import {
parsePlatformProbe,
@@ -7,6 +8,7 @@ import {
buildSudoCommand,
parseUpgradable,
execCommand,
decodeCommandOutput,
} from "@termix-ssh/plugin-sdk/host-commands";
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", () => {
it("leaves the command out of the timeout error", async () => {
const client = { exec: () => {} } as never;
@@ -124,4 +152,64 @@ describe("execCommand", () => {
/^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�");
});
});