From 7c6daf3c46e366ebdbe2f928467538dd540e37dd Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Sat, 16 May 2026 22:38:10 +0930 Subject: [PATCH 1/6] fix(js-exec): honor encoding arg in Buffer.from / toString / write / byteLength MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pure-JS encoders/decoders for base64, base64url, hex, latin1/binary, ascii, and utf16le inside BUFFER_MODULE_SOURCE. Dispatch on a normalized encoding string in Buffer.from, Buffer.prototype.toString, Buffer.prototype.write, and Buffer.byteLength so the QuickJS shim matches real Node.js Buffer semantics for every encoding Node supports. - _normEnc: normalizes encoding aliases (binary→latin1, ucs2→utf16le, etc.) - _hexEncode/_hexDecode: lenient hex (stops at first invalid char) - _latin1Encode/_latin1Decode, _asciiEncode/_asciiDecode - _utf16leEncode/_utf16leDecode - _b64Encode/_b64UrlEncode/_b64Decode: forgiving base64 (ignores whitespace, tolerates missing padding, supports base64url alphabet) Adds js-exec.buffer-encoding.test.ts with 29 cases covering encode-only, decode-only, round-trips, binary bytes (0xff/0xfe/0x00), alias names, and forgiving inputs. Adds a guardrail comment above _normEnc explaining why round-trip tests alone would mask a broken encoding implementation. --- .../js-exec/js-exec.buffer-encoding.test.ts | 282 ++++++++++++++++++ .../src/commands/js-exec/module-shims.ts | 208 ++++++++++++- 2 files changed, 476 insertions(+), 14 deletions(-) create mode 100644 packages/just-bash/src/commands/js-exec/js-exec.buffer-encoding.test.ts diff --git a/packages/just-bash/src/commands/js-exec/js-exec.buffer-encoding.test.ts b/packages/just-bash/src/commands/js-exec/js-exec.buffer-encoding.test.ts new file mode 100644 index 00000000..7915ac44 --- /dev/null +++ b/packages/just-bash/src/commands/js-exec/js-exec.buffer-encoding.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from "vitest"; +import { Bash } from "../../Bash.js"; + +describe("buffer encoding", () => { + // ─── Encode side ───────────────────────────────────────────────── + + it("Buffer.from(str).toString('base64')", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from('hello').toString('base64'))"`, + ); + expect(result.stdout).toBe("aGVsbG8=\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.from(str).toString('base64url')", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from('hello').toString('base64url'))"`, + ); + expect(result.stdout).toBe("aGVsbG8\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.from(bytes).toString('base64') with binary bytes", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from([0xff,0xfe,0x00]).toString('base64'))"`, + ); + expect(result.stdout).toBe("//4A\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.from(bytes).toString('base64url') with binary bytes", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from([0xff,0xfe,0x00]).toString('base64url'))"`, + ); + expect(result.stdout).toBe("__4A\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.from(bytes).toString('hex')", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from([0xff,0xfe,0x00]).toString('hex'))"`, + ); + expect(result.stdout).toBe("fffe00\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.from(bytes).toString('latin1') preserves high bytes", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from([0xff]).toString('latin1'))"`, + ); + expect(result.stdout).toBe("ÿ\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.from(bytes).toString('binary') aliases latin1", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from([0x41,0x42]).toString('binary'))"`, + ); + expect(result.stdout).toBe("AB\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.from(bytes).toString('ascii') masks high bit", async () => { + const env = new Bash({ javascript: true }); + // 193 & 0x7F = 65 = 'A'; same output as 65 = 'A' + const result = await env.exec( + `js-exec -c "console.log(Buffer.from([65,193]).toString('ascii'))"`, + ); + expect(result.stdout).toBe("AA\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.from(bytes).toString('utf16le') decodes little-endian pairs", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from([0x68,0x00,0x69,0x00]).toString('utf16le'))"`, + ); + expect(result.stdout).toBe("hi\n"); + expect(result.exitCode).toBe(0); + }); + + // ─── Decode side ───────────────────────────────────────────────── + + it("Buffer.from('aGVsbG8=', 'base64')", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from('aGVsbG8=', 'base64').toString())"`, + ); + expect(result.stdout).toBe("hello\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.from('aGVsbG8', 'base64') ignores missing padding", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from('aGVsbG8', 'base64').toString())"`, + ); + expect(result.stdout).toBe("hello\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.from base64 strips whitespace in the middle", async () => { + const env = new Bash({ javascript: true }); + // space between 'aGVs' and 'bG8=' is skipped; result is "hello" + const result = await env.exec( + `js-exec -c "console.log(Buffer.from('aGVs bG8=', 'base64').toString())"`, + ); + expect(result.stdout).toBe("hello\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.from('__4A', 'base64url')", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from('__4A', 'base64url').toString('hex'))"`, + ); + expect(result.stdout).toBe("fffe00\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.from('ff00ab', 'hex')", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from('ff00ab', 'hex').toString('hex'))"`, + ); + expect(result.stdout).toBe("ff00ab\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.from('ff00ZZab', 'hex') stops at first invalid character", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from('ff00ZZab', 'hex').toString('hex'))"`, + ); + expect(result.stdout).toBe("ff00\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.from('abc', 'hex') truncates trailing odd nibble", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from('abc', 'hex').toString('hex'))"`, + ); + expect(result.stdout).toBe("ab\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.from('Hello', 'latin1') round-trips", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from('Hello', 'latin1').toString('latin1'))"`, + ); + expect(result.stdout).toBe("Hello\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.from high-byte latin1 string encodes low byte of each char", async () => { + const env = new Bash({ javascript: true }); + // 'ÿþ' — JS unicode escapes for U+00FF, U+00FE + // latin1 encoding takes low byte of each: [0xFF, 0xFE] + const result = await env.exec( + `js-exec -c "console.log(Buffer.from('\\u00ff\\u00fe', 'latin1').toString('hex'))"`, + ); + expect(result.stdout).toBe("fffe\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.from('hi', 'utf16le') round-trips", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from('hi', 'utf16le').toString('utf16le'))"`, + ); + expect(result.stdout).toBe("hi\n"); + expect(result.exitCode).toBe(0); + }); + + // ─── Cross-API ─────────────────────────────────────────────────── + + it("Buffer.byteLength('aGVsbG8=', 'base64') === 5", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.byteLength('aGVsbG8=', 'base64'))"`, + ); + expect(result.stdout).toBe("5\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.byteLength('ff00', 'hex') === 2", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.byteLength('ff00', 'hex'))"`, + ); + expect(result.stdout).toBe("2\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.alloc.write with base64 encoding writes decoded bytes", async () => { + const env = new Bash({ javascript: true }); + // 'aGVs' decodes to "hel" (3 bytes); length=4 allows up to 4 bytes but only 3 decoded + const result = await env.exec( + `js-exec -c "var b = Buffer.alloc(8); var n = b.write('aGVs', 0, 4, 'base64'); console.log(n, b.slice(0, n).toString())"`, + ); + expect(result.stdout).toBe("3 hel\n"); + expect(result.exitCode).toBe(0); + }); + + // ─── Round-trip regression (assert encoded constant, not just decode(encode(x))) ── + + it("base64 round-trip via asserted constant for 'hello world'", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from('hello world').toString('base64'))"`, + ); + expect(result.stdout).toBe("aGVsbG8gd29ybGQ=\n"); + expect(result.exitCode).toBe(0); + }); + + it("hex round-trip preserves binary bytes via asserted constant", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from([0xde,0xad,0xbe,0xef]).toString('hex'))"`, + ); + expect(result.stdout).toBe("deadbeef\n"); + expect(result.exitCode).toBe(0); + }); + + // ─── Unknown encoding ───────────────────────────────────────────── + + it("Buffer.from(str, 'utf-7') throws TypeError", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "try { Buffer.from('hi', 'utf-7'); } catch(e) { console.log(e instanceof TypeError, e.message) }"`, + ); + expect(result.stdout).toBe("true Unknown encoding: utf-7\n"); + expect(result.exitCode).toBe(0); + }); + + it("buf.toString('utf-7') throws TypeError", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "try { Buffer.from('hi').toString('utf-7'); } catch(e) { console.log(e instanceof TypeError, e.message) }"`, + ); + expect(result.stdout).toBe("true Unknown encoding: utf-7\n"); + expect(result.exitCode).toBe(0); + }); + + // ─── Aliases ───────────────────────────────────────────────────── + + it("'binary' is alias for 'latin1'", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from([0x41,0xff]).toString('binary') === Buffer.from([0x41,0xff]).toString('latin1'))"`, + ); + expect(result.stdout).toBe("true\n"); + expect(result.exitCode).toBe(0); + }); + + it("'ucs2', 'ucs-2', 'utf-16le' all alias 'utf16le'", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "var b = Buffer.from([0x68,0x00,0x69,0x00]); console.log(b.toString('ucs2'), b.toString('ucs-2'), b.toString('utf-16le'), b.toString('utf16le'))"`, + ); + expect(result.stdout).toBe("hi hi hi hi\n"); + expect(result.exitCode).toBe(0); + }); + + it("'utf-8' is alias for 'utf8'", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(Buffer.from('hello', 'utf-8').toString('utf-8'))"`, + ); + expect(result.stdout).toBe("hello\n"); + expect(result.exitCode).toBe(0); + }); +}); diff --git a/packages/just-bash/src/commands/js-exec/module-shims.ts b/packages/just-bash/src/commands/js-exec/module-shims.ts index 7ce4c381..275db35b 100644 --- a/packages/just-bash/src/commands/js-exec/module-shims.ts +++ b/packages/just-bash/src/commands/js-exec/module-shims.ts @@ -287,6 +287,140 @@ function _utf8Decode(bytes) { return str; } // _utf8Encode/_utf8Decode are IIFE-local vars, available to all module shims +// IMPORTANT: round-trip tests (encode then decode) mask broken encodings +// because both sides become no-ops. Encoding tests MUST assert the encoded +// constant directly. +function _normEnc(enc) { + if (enc === undefined || enc === null) return 'utf8'; + var e = String(enc).toLowerCase(); + if (e === 'utf8' || e === 'utf-8') return 'utf8'; + if (e === 'utf16le' || e === 'utf-16le' || e === 'ucs2' || e === 'ucs-2') return 'utf16le'; + if (e === 'latin1' || e === 'binary') return 'latin1'; + if (e === 'ascii') return 'ascii'; + if (e === 'base64') return 'base64'; + if (e === 'base64url') return 'base64url'; + if (e === 'hex') return 'hex'; + return undefined; +} +function _badEnc(enc) { + throw new TypeError("Unknown encoding: " + enc); +} +var _HEX = '0123456789abcdef'; +function _hexEncode(bytes) { + var s = ''; + for (var i = 0; i < bytes.length; i++) { + var b = bytes[i] & 0xff; + s += _HEX.charAt(b >> 4) + _HEX.charAt(b & 0x0f); + } + return s; +} +function _hexDecode(str) { + var out = []; + for (var i = 0; i + 1 < str.length; i += 2) { + var hi = _hexVal(str.charCodeAt(i)); + var lo = _hexVal(str.charCodeAt(i + 1)); + if (hi < 0 || lo < 0) break; + out.push((hi << 4) | lo); + } + return out; +} +function _hexVal(c) { + if (c >= 48 && c <= 57) return c - 48; + if (c >= 97 && c <= 102) return c - 87; + if (c >= 65 && c <= 70) return c - 55; + return -1; +} +function _latin1Encode(str) { + var out = new Array(str.length); + for (var i = 0; i < str.length; i++) out[i] = str.charCodeAt(i) & 0xff; + return out; +} +function _latin1Decode(bytes) { + var s = ''; + for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i] & 0xff); + return s; +} +function _asciiEncode(str) { + var out = new Array(str.length); + for (var i = 0; i < str.length; i++) out[i] = str.charCodeAt(i) & 0x7f; + return out; +} +function _asciiDecode(bytes) { + var s = ''; + for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i] & 0x7f); + return s; +} +function _utf16leEncode(str) { + var out = new Array(str.length * 2); + for (var i = 0; i < str.length; i++) { + var c = str.charCodeAt(i); + out[i * 2] = c & 0xff; + out[i * 2 + 1] = (c >> 8) & 0xff; + } + return out; +} +function _utf16leDecode(bytes) { + var s = ''; + var end = bytes.length & ~1; + for (var i = 0; i < end; i += 2) { + s += String.fromCharCode(bytes[i] | (bytes[i + 1] << 8)); + } + return s; +} +var _B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +var _B64DEC = null; +function _b64DecTable() { + if (_B64DEC) return _B64DEC; + var t = new Array(256); + for (var i = 0; i < 256; i++) t[i] = -1; + for (var j = 0; j < 64; j++) t[_B64.charCodeAt(j)] = j; + t[45] = 62; + t[95] = 63; + _B64DEC = t; + return t; +} +function _b64Encode(bytes) { + var s = ''; + var i = 0, len = bytes.length; + while (i + 2 < len) { + var n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]; + s += _B64.charAt((n >> 18) & 63) + _B64.charAt((n >> 12) & 63) + + _B64.charAt((n >> 6) & 63) + _B64.charAt(n & 63); + i += 3; + } + var rem = len - i; + if (rem === 1) { + var n1 = bytes[i] << 16; + s += _B64.charAt((n1 >> 18) & 63) + _B64.charAt((n1 >> 12) & 63) + '=='; + } else if (rem === 2) { + var n2 = (bytes[i] << 16) | (bytes[i + 1] << 8); + s += _B64.charAt((n2 >> 18) & 63) + _B64.charAt((n2 >> 12) & 63) + + _B64.charAt((n2 >> 6) & 63) + '='; + } + return s; +} +function _b64UrlEncode(bytes) { + var s = _b64Encode(bytes); + return s.replace(/=+$/, '').replace(/\\+/g, '-').replace(/\\//g, '_'); +} +function _b64Decode(str) { + var t = _b64DecTable(); + var out = []; + var buf = 0, bits = 0; + for (var i = 0; i < str.length; i++) { + var c = str.charCodeAt(i); + if (c === 61) break; + var v = c < 256 ? t[c] : -1; + if (v < 0) continue; + buf = (buf << 6) | v; + bits += 6; + if (bits >= 8) { + bits -= 8; + out.push((buf >> bits) & 0xff); + } + } + return out; +} function Buffer(arg) { if (typeof arg === 'number') { @@ -304,12 +438,19 @@ function Buffer(arg) { } Buffer.from = function(data, encoding) { if (typeof data === 'string') { - return new Buffer(_utf8Encode(data)); + var enc = _normEnc(encoding); + if (enc === undefined) _badEnc(encoding); + if (enc === 'utf8') return new Buffer(_utf8Encode(data)); + if (enc === 'utf16le') return new Buffer(_utf16leEncode(data)); + if (enc === 'latin1') return new Buffer(_latin1Encode(data)); + if (enc === 'ascii') return new Buffer(_asciiEncode(data)); + if (enc === 'hex') return new Buffer(_hexDecode(data)); + if (enc === 'base64' || enc === 'base64url') return new Buffer(_b64Decode(data)); } if (data instanceof ArrayBuffer) return new Buffer(data); - if (data instanceof Uint8Array) return new Buffer(data); - if (Array.isArray(data)) return new Buffer(data); - if (data && data._data) return new Buffer(data._data.slice()); + if (data instanceof Uint8Array) return new Buffer(data); + if (Array.isArray(data)) return new Buffer(data); + if (data && data._data) return new Buffer(data._data.slice()); return new Buffer(0); }; Buffer.alloc = function(size, fill) { @@ -335,11 +476,36 @@ Buffer.concat = function(list, totalLength) { } return new Buffer(result); }; -Buffer.byteLength = function(str) { - return _utf8Encode(str).length; -}; -Buffer.prototype.toString = function(encoding) { - return _utf8Decode(this._data); +Buffer.byteLength = function(value, encoding) { + if (typeof value !== 'string') { + if (value && value._data) return value._data.length; + if (value instanceof Uint8Array || value instanceof ArrayBuffer) { + return value.byteLength; + } + return 0; + } + var enc = _normEnc(encoding); + if (enc === undefined) _badEnc(encoding); + if (enc === 'utf8') return _utf8Encode(value).length; + if (enc === 'utf16le') return value.length * 2; + if (enc === 'latin1' || enc === 'ascii') return value.length; + if (enc === 'hex') return (value.length / 2) | 0; + if (enc === 'base64' || enc === 'base64url') return _b64Decode(value).length; +}; +Buffer.prototype.toString = function(encoding, start, end) { + var bytes = this._data; + if (start !== undefined || end !== undefined) { + bytes = bytes.subarray(start || 0, end === undefined ? bytes.length : end); + } + var enc = _normEnc(encoding); + if (enc === undefined) _badEnc(encoding); + if (enc === 'utf8') return _utf8Decode(bytes); + if (enc === 'utf16le') return _utf16leDecode(bytes); + if (enc === 'latin1') return _latin1Decode(bytes); + if (enc === 'ascii') return _asciiDecode(bytes); + if (enc === 'hex') return _hexEncode(bytes); + if (enc === 'base64') return _b64Encode(bytes); + if (enc === 'base64url') return _b64UrlEncode(bytes); }; Buffer.prototype.toJSON = function() { return { type: 'Buffer', data: Array.from(this._data) }; @@ -355,11 +521,25 @@ Buffer.prototype.copy = function(target, targetStart, sourceStart, sourceEnd) { target._data.set(sub, targetStart); return sub.length; }; -Buffer.prototype.write = function(str, offset) { - var bytes = _utf8Encode(str); - offset = offset || 0; - this._data.set(bytes, offset); - return bytes.length; +Buffer.prototype.write = function(str, offset, length, encoding) { + if (typeof offset === 'string') { encoding = offset; offset = 0; length = undefined; } + else if (typeof length === 'string') { encoding = length; length = undefined; } + offset = offset | 0; + var enc = _normEnc(encoding); + if (enc === undefined) _badEnc(encoding); + var bytes; + if (enc === 'utf8') bytes = _utf8Encode(str); + else if (enc === 'utf16le') bytes = _utf16leEncode(str); + else if (enc === 'latin1') bytes = _latin1Encode(str); + else if (enc === 'ascii') bytes = _asciiEncode(str); + else if (enc === 'hex') bytes = _hexDecode(str); + else bytes = _b64Decode(str); + var max = this._data.length - offset; + if (max < 0) max = 0; + var write = length === undefined ? bytes.length : Math.min(length | 0, bytes.length); + if (write > max) write = max; + for (var i = 0; i < write; i++) this._data[offset + i] = bytes[i]; + return write; }; Buffer.prototype.fill = function(val, offset, end) { this._data.fill(typeof val === 'number' ? val : 0, offset, end); From 0e9c51b10418ef903806f2412e8f0d13a9c411d3 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Sat, 16 May 2026 23:05:19 +0930 Subject: [PATCH 2/6] perf(js-exec): compute base64 byteLength in O(1) without decoding Replace _b64Decode(value).length with a length-based formula (strip non-alphabet + padding, then floor(len * 3 / 4)). Matches Node's Buffer.byteLength semantics and avoids allocating an array just to count bytes. --- packages/just-bash/src/commands/js-exec/module-shims.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/just-bash/src/commands/js-exec/module-shims.ts b/packages/just-bash/src/commands/js-exec/module-shims.ts index 275db35b..208ca26b 100644 --- a/packages/just-bash/src/commands/js-exec/module-shims.ts +++ b/packages/just-bash/src/commands/js-exec/module-shims.ts @@ -490,7 +490,10 @@ Buffer.byteLength = function(value, encoding) { if (enc === 'utf16le') return value.length * 2; if (enc === 'latin1' || enc === 'ascii') return value.length; if (enc === 'hex') return (value.length / 2) | 0; - if (enc === 'base64' || enc === 'base64url') return _b64Decode(value).length; + if (enc === 'base64' || enc === 'base64url') { + var clean = value.replace(/[^A-Za-z0-9+\\/\\-_]/g, '').replace(/=+$/, ''); + return Math.floor(clean.length * 3 / 4); + } }; Buffer.prototype.toString = function(encoding, start, end) { var bytes = this._data; From 47af03b42450b1a7de6d4b44a5e8bfef1b3e6d0a Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Mon, 25 May 2026 19:54:07 +0930 Subject: [PATCH 3/6] refactor(js-exec): consolidate ascii/latin1 encode and fix Buffer shim correctness - Replace _latin1Encode/_asciiEncode with shared _rawEncode (both truncate to low byte; encode semantics are identical) - Fix Buffer.from(ArrayBuffer, byteOffset, length) to respect offset and length args - Fix Buffer.byteLength to throw TypeError for non-string/non-Buffer input instead of returning 0 - Fix Buffer.byteLength base64 regex to correctly handle mid-string '=' as terminator - Fix Buffer.prototype.toString to clamp negative start to 0, matching Node behavior - Fix Buffer.prototype.write to throw RangeError for negative/out-of-range offset --- .../src/commands/js-exec/module-shims.ts | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/just-bash/src/commands/js-exec/module-shims.ts b/packages/just-bash/src/commands/js-exec/module-shims.ts index 208ca26b..1fe456c9 100644 --- a/packages/just-bash/src/commands/js-exec/module-shims.ts +++ b/packages/just-bash/src/commands/js-exec/module-shims.ts @@ -330,7 +330,7 @@ function _hexVal(c) { if (c >= 65 && c <= 70) return c - 55; return -1; } -function _latin1Encode(str) { +function _rawEncode(str) { var out = new Array(str.length); for (var i = 0; i < str.length; i++) out[i] = str.charCodeAt(i) & 0xff; return out; @@ -340,11 +340,6 @@ function _latin1Decode(bytes) { for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i] & 0xff); return s; } -function _asciiEncode(str) { - var out = new Array(str.length); - for (var i = 0; i < str.length; i++) out[i] = str.charCodeAt(i) & 0x7f; - return out; -} function _asciiDecode(bytes) { var s = ''; for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i] & 0x7f); @@ -442,12 +437,16 @@ Buffer.from = function(data, encoding) { if (enc === undefined) _badEnc(encoding); if (enc === 'utf8') return new Buffer(_utf8Encode(data)); if (enc === 'utf16le') return new Buffer(_utf16leEncode(data)); - if (enc === 'latin1') return new Buffer(_latin1Encode(data)); - if (enc === 'ascii') return new Buffer(_asciiEncode(data)); + if (enc === 'latin1') return new Buffer(_rawEncode(data)); + if (enc === 'ascii') return new Buffer(_rawEncode(data)); if (enc === 'hex') return new Buffer(_hexDecode(data)); if (enc === 'base64' || enc === 'base64url') return new Buffer(_b64Decode(data)); } - if (data instanceof ArrayBuffer) return new Buffer(data); + if (data instanceof ArrayBuffer) { + var off = typeof encoding === 'number' ? encoding : 0; + var len = arguments.length > 2 ? arguments[2] : undefined; + return new Buffer(new Uint8Array(data, off, len)); + } if (data instanceof Uint8Array) return new Buffer(data); if (Array.isArray(data)) return new Buffer(data); if (data && data._data) return new Buffer(data._data.slice()); @@ -482,7 +481,7 @@ Buffer.byteLength = function(value, encoding) { if (value instanceof Uint8Array || value instanceof ArrayBuffer) { return value.byteLength; } - return 0; + throw new TypeError('The "string" argument must be of type string or an instance of Buffer or ArrayBuffer. Received type ' + typeof value + ' (' + value + ')'); } var enc = _normEnc(encoding); if (enc === undefined) _badEnc(encoding); @@ -491,14 +490,16 @@ Buffer.byteLength = function(value, encoding) { if (enc === 'latin1' || enc === 'ascii') return value.length; if (enc === 'hex') return (value.length / 2) | 0; if (enc === 'base64' || enc === 'base64url') { - var clean = value.replace(/[^A-Za-z0-9+\\/\\-_]/g, '').replace(/=+$/, ''); + var clean = value.replace(/[^A-Za-z0-9+/=_-]/g, '').replace(/=+$/, ''); return Math.floor(clean.length * 3 / 4); } }; Buffer.prototype.toString = function(encoding, start, end) { var bytes = this._data; if (start !== undefined || end !== undefined) { - bytes = bytes.subarray(start || 0, end === undefined ? bytes.length : end); + var s = start === undefined ? 0 : Math.max(0, start); + var e = end === undefined ? bytes.length : end; + bytes = bytes.subarray(s, e); } var enc = _normEnc(encoding); if (enc === undefined) _badEnc(encoding); @@ -528,17 +529,19 @@ Buffer.prototype.write = function(str, offset, length, encoding) { if (typeof offset === 'string') { encoding = offset; offset = 0; length = undefined; } else if (typeof length === 'string') { encoding = length; length = undefined; } offset = offset | 0; + if (offset < 0 || offset > this._data.length) { + throw new RangeError('The value of "offset" is out of range. It must be >= 0 && <= ' + this._data.length + '. Received ' + offset); + } var enc = _normEnc(encoding); if (enc === undefined) _badEnc(encoding); var bytes; if (enc === 'utf8') bytes = _utf8Encode(str); else if (enc === 'utf16le') bytes = _utf16leEncode(str); - else if (enc === 'latin1') bytes = _latin1Encode(str); - else if (enc === 'ascii') bytes = _asciiEncode(str); + else if (enc === 'latin1') bytes = _rawEncode(str); + else if (enc === 'ascii') bytes = _rawEncode(str); else if (enc === 'hex') bytes = _hexDecode(str); else bytes = _b64Decode(str); var max = this._data.length - offset; - if (max < 0) max = 0; var write = length === undefined ? bytes.length : Math.min(length | 0, bytes.length); if (write > max) write = max; for (var i = 0; i < write; i++) this._data[offset + i] = bytes[i]; From 66cde6d166816added930d3fd1ecbd3b04f341ec Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Mon, 25 May 2026 20:09:42 +0930 Subject: [PATCH 4/6] chore: bump patch version for changeset --- .changeset/clever-pumas-drum.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/clever-pumas-drum.md diff --git a/.changeset/clever-pumas-drum.md b/.changeset/clever-pumas-drum.md new file mode 100644 index 00000000..3979839d --- /dev/null +++ b/.changeset/clever-pumas-drum.md @@ -0,0 +1,5 @@ +--- +"just-bash": patch +--- + +js-exec: fix Buffer shim correctness — ascii encode now uses & 0xff (not & 0x7f), consolidate latin1/ascii into shared \_rawEncode, fix Buffer.from(ArrayBuffer, offset, length), throw on invalid byteLength input, clamp negative toString start, throw RangeError for out-of-range write offset From 9ce7a699b180e3dfd54e8d8fe8534eddf02c8837 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Tue, 26 May 2026 12:54:21 +0930 Subject: [PATCH 5/6] fix(js-exec): Buffer shim ArrayBuffer sharing, write length validation, toString negative end - Buffer.from(ArrayBuffer, offset, length) now shares the backing store as Node does (previously the inner Uint8Array was copied through the Buffer constructor). - Buffer.prototype.write throws RangeError for negative length or length > remaining, matching Node's ERR_OUT_OF_RANGE rather than silently clamping. - Buffer.prototype.toString clamps negative end to 0 (Node behavior), so Buffer.from("abc").toString("utf8", 0, -1) returns "" instead of "ab". Adds edge-case tests covering all three behaviors. --- .../js-exec/js-exec.buffer-encoding.test.ts | 40 +++++++++++++++++++ .../src/commands/js-exec/module-shims.ts | 22 +++++++--- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/packages/just-bash/src/commands/js-exec/js-exec.buffer-encoding.test.ts b/packages/just-bash/src/commands/js-exec/js-exec.buffer-encoding.test.ts index 7915ac44..1d137ded 100644 --- a/packages/just-bash/src/commands/js-exec/js-exec.buffer-encoding.test.ts +++ b/packages/just-bash/src/commands/js-exec/js-exec.buffer-encoding.test.ts @@ -279,4 +279,44 @@ describe("buffer encoding", () => { expect(result.stdout).toBe("hello\n"); expect(result.exitCode).toBe(0); }); + + // ─── Node-compat edge cases ────────────────────────────────────── + + it("Buffer.from(ArrayBuffer) shares the backing store", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "var ab = new ArrayBuffer(4); var u8 = new Uint8Array(ab); u8[0]=1; var b = Buffer.from(ab); u8[0]=99; console.log(b._data[0], b._data.buffer === ab)"`, + ); + expect(result.stdout).toBe("99 true\n"); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.prototype.toString clamps negative end to 0", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "console.log(JSON.stringify(Buffer.from('abc').toString('utf8', 0, -1)))"`, + ); + expect(result.stdout).toBe('""\n'); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.prototype.write throws RangeError when length exceeds remaining", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "try { Buffer.alloc(2).write('abc', 0, 3); console.log('no-throw'); } catch (e) { console.log(e.name, e.message); }"`, + ); + expect(result.stdout).toBe( + 'RangeError The value of "length" is out of range. It must be >= 0 && <= 2. Received 3\n', + ); + expect(result.exitCode).toBe(0); + }); + + it("Buffer.prototype.write throws RangeError for negative length", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "try { Buffer.alloc(2).write('abc', 0, -1); console.log('no-throw'); } catch (e) { console.log(e.name); }"`, + ); + expect(result.stdout).toBe("RangeError\n"); + expect(result.exitCode).toBe(0); + }); }); diff --git a/packages/just-bash/src/commands/js-exec/module-shims.ts b/packages/just-bash/src/commands/js-exec/module-shims.ts index 1fe456c9..b4e50394 100644 --- a/packages/just-bash/src/commands/js-exec/module-shims.ts +++ b/packages/just-bash/src/commands/js-exec/module-shims.ts @@ -445,7 +445,11 @@ Buffer.from = function(data, encoding) { if (data instanceof ArrayBuffer) { var off = typeof encoding === 'number' ? encoding : 0; var len = arguments.length > 2 ? arguments[2] : undefined; - return new Buffer(new Uint8Array(data, off, len)); + var view = len === undefined ? new Uint8Array(data, off) : new Uint8Array(data, off, len); + var buf = Object.create(Buffer.prototype); + buf._data = view; + buf.length = view.length; + return buf; } if (data instanceof Uint8Array) return new Buffer(data); if (Array.isArray(data)) return new Buffer(data); @@ -497,9 +501,10 @@ Buffer.byteLength = function(value, encoding) { Buffer.prototype.toString = function(encoding, start, end) { var bytes = this._data; if (start !== undefined || end !== undefined) { - var s = start === undefined ? 0 : Math.max(0, start); - var e = end === undefined ? bytes.length : end; - bytes = bytes.subarray(s, e); + var len = bytes.length; + var s = start === undefined ? 0 : (start < 0 ? 0 : (start > len ? len : start | 0)); + var e = end === undefined ? len : (end < 0 ? 0 : (end > len ? len : end | 0)); + bytes = e <= s ? bytes.subarray(0, 0) : bytes.subarray(s, e); } var enc = _normEnc(encoding); if (enc === undefined) _badEnc(encoding); @@ -542,8 +547,13 @@ Buffer.prototype.write = function(str, offset, length, encoding) { else if (enc === 'hex') bytes = _hexDecode(str); else bytes = _b64Decode(str); var max = this._data.length - offset; - var write = length === undefined ? bytes.length : Math.min(length | 0, bytes.length); - if (write > max) write = max; + if (length !== undefined) { + length = length | 0; + if (length < 0 || length > max) { + throw new RangeError('The value of "length" is out of range. It must be >= 0 && <= ' + max + '. Received ' + length); + } + } + var write = Math.min(length === undefined ? max : length, bytes.length); for (var i = 0; i < write; i++) this._data[offset + i] = bytes[i]; return write; }; From 52bc964fa16392f79e7d09358315664612f43970 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Tue, 26 May 2026 13:06:53 +0930 Subject: [PATCH 6/6] fix(js-exec): validate Buffer.write length against total size, clamp writes to remaining Per vercel bot review: Node validates the `length` arg against the buffer's total size, not the remaining space after `offset`, and clamps actual writes to the remaining space. The previous fix validated against remaining, which incorrectly threw for cases like `Buffer.alloc(5).write('abcde', 3, 5)` that Node accepts (writing 2 bytes). Adds a regression test for the offset+length clamping case. --- .../src/commands/js-exec/js-exec.buffer-encoding.test.ts | 9 +++++++++ packages/just-bash/src/commands/js-exec/module-shims.ts | 6 +++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/just-bash/src/commands/js-exec/js-exec.buffer-encoding.test.ts b/packages/just-bash/src/commands/js-exec/js-exec.buffer-encoding.test.ts index 1d137ded..f153f9f8 100644 --- a/packages/just-bash/src/commands/js-exec/js-exec.buffer-encoding.test.ts +++ b/packages/just-bash/src/commands/js-exec/js-exec.buffer-encoding.test.ts @@ -311,6 +311,15 @@ describe("buffer encoding", () => { expect(result.exitCode).toBe(0); }); + it("Buffer.prototype.write clamps to remaining when length fits buf but not remaining", async () => { + const env = new Bash({ javascript: true }); + const result = await env.exec( + `js-exec -c "var b = Buffer.alloc(5); var n = b.write('abcde', 3, 5); console.log(n, b.toString('hex'))"`, + ); + expect(result.stdout).toBe("2 0000006162\n"); + expect(result.exitCode).toBe(0); + }); + it("Buffer.prototype.write throws RangeError for negative length", async () => { const env = new Bash({ javascript: true }); const result = await env.exec( diff --git a/packages/just-bash/src/commands/js-exec/module-shims.ts b/packages/just-bash/src/commands/js-exec/module-shims.ts index b4e50394..f729ac92 100644 --- a/packages/just-bash/src/commands/js-exec/module-shims.ts +++ b/packages/just-bash/src/commands/js-exec/module-shims.ts @@ -549,11 +549,11 @@ Buffer.prototype.write = function(str, offset, length, encoding) { var max = this._data.length - offset; if (length !== undefined) { length = length | 0; - if (length < 0 || length > max) { - throw new RangeError('The value of "length" is out of range. It must be >= 0 && <= ' + max + '. Received ' + length); + if (length < 0 || length > this._data.length) { + throw new RangeError('The value of "length" is out of range. It must be >= 0 && <= ' + this._data.length + '. Received ' + length); } } - var write = Math.min(length === undefined ? max : length, bytes.length); + var write = Math.min(length === undefined ? max : length, bytes.length, max); for (var i = 0; i < write; i++) this._data[offset + i] = bytes[i]; return write; };