mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15: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
|
### 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
|
||||||
|
|
||||||
|
|||||||
@@ -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�");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user