diff --git a/src/protocols/native-api.ts b/src/protocols/native-api.ts index ecf0c4d..e834274 100644 --- a/src/protocols/native-api.ts +++ b/src/protocols/native-api.ts @@ -307,7 +307,16 @@ export function challengeResponse( return `00${hash.digest("hex")}`; } -/** Build an API command word `=name=value` attribute word. */ +/** + * Build an API command word `=name=value` attribute word. + * + * The value needs **no escaping**: words are length-prefix framed on the wire + * ({@link encodeWord} UTF-8-encodes by byte length), so any UTF-8 content — `=`, + * spaces, CR/LF, NUL, multibyte characters — passes through verbatim, and + * {@link parseReply} splits on the *second* `=` so a value that contains `=` is + * read back whole. Round-trip pinned in `test/unit/native-api.test.ts` + * ("attribute value round-trip (JG-15)"). + */ export function attributeWord(name: string, value: string): string { return `=${name}=${value}`; } diff --git a/test/unit/native-api.test.ts b/test/unit/native-api.test.ts index f5ecd4b..6c62cbe 100644 --- a/test/unit/native-api.test.ts +++ b/test/unit/native-api.test.ts @@ -117,6 +117,61 @@ describe("native-api word and sentence framing", () => { }); }); +/** + * JG-15 — attribute *value* round-trip across the codec, the worry case the + * other transports escape but native-api does not. The API protocol is + * length-prefix framed, not delimiter-escaped: a word is a UTF-8 byte count, so + * any UTF-8 content — `=`, spaces, CR/LF, NUL, multibyte characters — survives + * verbatim with no escaping. `parseReply` splits `=name=value` on the *second* `=` + * ({@link parseReply} uses `indexOf("=", 1)`), so a value that itself contains + * `=` is returned whole, not truncated. This pins that no caller-side trimming + * or naive `split("=")` corrupts a value end-to-end: + * `attributeWord → encodeSentence → SentenceReader → parseReply`. + */ +describe("native-api attribute value round-trip (JG-15)", () => { + /** Send one `=name=value` attribute through the full codec and read it back. */ + function roundTrip(name: string, value: string): string | undefined { + const words = ["!re", attributeWord(name, value)]; + const reader = new SentenceReader(); + const [sentence, ...rest] = reader.push(encodeSentence(words)); + expect(rest).toHaveLength(0); + if (!sentence) throw new Error("codec decoded no sentence"); + return readAttribute(parseReply(sentence), name); + } + + const cases: Array<[label: string, value: string]> = [ + ["a value containing '=' (split on the second =)", "key1=val1=val2"], + ["an embedded RouterOS expression with =", "([/system/clock/get date]=x)"], + ["spaces (an unquoted comment)", "office uplink — do not touch"], + ["a literal CR/LF inside the value", "line1\r\nline2"], + ["a tab and other control bytes", "a\tb\x01c"], + ["a NUL byte mid-value (framing is length-prefixed)", "before\x00after"], + ["multibyte UTF-8", "café — ☃ — 日本語 — 🛰️"], + [ + "RouterOS escapes left literal (no double-escaping)", + '\\"quoted\\" $x \\n ;', + ], + ["leading/trailing whitespace is preserved", " padded "], + ["the empty value", ""], + ["a long value spanning a multi-byte length prefix", "z".repeat(0x250)], + ]; + + for (const [label, value] of cases) { + test(label, () => { + expect(roundTrip("comment", value)).toBe(value); + }); + } + + test("a value-less attribute (`=name=`) reads back as the empty string", () => { + // parseReply maps both `=name=` and a bare `=name` to "". + expect(roundTrip("disabled", "")).toBe(""); + const reader = new SentenceReader(); + const [sentence] = reader.push(encodeSentence(["!re", "=name"])); + if (!sentence) throw new Error("codec decoded no sentence"); + expect(readAttribute(parseReply(sentence), "name")).toBe(""); + }); +}); + describe("native-api SentenceReader streaming", () => { test("reassembles a sentence split across arbitrary byte boundaries", () => { const sentence = encodeSentence(["!re", "=name=ether1", "=type=ether"]); diff --git a/test/unit/ssh.test.ts b/test/unit/ssh.test.ts index e8a31cb..e6dbd4f 100644 --- a/test/unit/ssh.test.ts +++ b/test/unit/ssh.test.ts @@ -77,6 +77,18 @@ describe("SshExecClient", () => { expect(argv.at(-1)).toBe("/system/identity/print"); }); + test("never forces a PTY — RouterOS grants none and `-tt` hangs it", () => { + // Per-command batch exec must not *force* a tty (the terminal relay guards + // the same in terminal.test.ts). `-tt` makes the host ssh demand a PTY that + // RouterOS refuses, hanging the session (CHR 7.23.1 grounded). Disabling a + // PTY (`-T`, `RequestTTY=no`) is the opposite and would be fine, so only the + // forcing forms are forbidden. + const argv = client({ exitCode: 0, stdout: "", stderr: "" }).argv("/x"); + expect(argv).not.toContain("-t"); + expect(argv).not.toContain("-tt"); + expect(argv.join(" ")).not.toMatch(/RequestTTY[= ](?:force|yes)/i); + }); + test("exec returns cleaned stdout on success", async () => { const out = await client({ exitCode: 0,