mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735)
This commit is contained in:
1 parent
1f8b006831
commit
3173f45e7e
3 files changed
+124
-5
No files matched your search
@@ -13,6 +13,7 @@ Plugin API 1.13.0.
|
||||
### 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
|
||||
- `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
|
||||
|
||||
|
||||
@@ -21,6 +21,30 @@ export interface ExecResult {
|
||||
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. */
|
||||
export function execCommand(
|
||||
client: Client,
|
||||
@@ -65,8 +89,10 @@ export function execCommand(
|
||||
}
|
||||
|
||||
stream = _stream;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
// Chunks are buffered and decoded once on close: a multi-byte character
|
||||
// can be split across chunks, and charset detection needs it whole.
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
let exitCode: number | null = null;
|
||||
|
||||
stream
|
||||
@@ -75,11 +101,15 @@ export function execCommand(
|
||||
settled = true;
|
||||
exitCode = typeof code === "number" ? code : null;
|
||||
cleanup();
|
||||
resolve({ stdout, stderr, code: exitCode });
|
||||
resolve({
|
||||
stdout: decodeCommandOutput(Buffer.concat(stdout)),
|
||||
stderr: decodeCommandOutput(Buffer.concat(stderr)),
|
||||
code: exitCode,
|
||||
});
|
||||
}
|
||||
})
|
||||
.on("data", (data: Buffer) => {
|
||||
stdout += data.toString("utf8");
|
||||
stdout.push(data);
|
||||
})
|
||||
.on("error", (streamErr: Error) => {
|
||||
if (!settled) {
|
||||
@@ -92,7 +122,7 @@ export function execCommand(
|
||||
if (stream.stderr) {
|
||||
stream.stderr
|
||||
.on("data", (data: Buffer) => {
|
||||
stderr += data.toString("utf8");
|
||||
stderr.push(data);
|
||||
})
|
||||
.on("error", (stderrErr: Error) => {
|
||||
if (!settled) {
|
||||
|
||||
@@ -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�");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user