Skip to content

Commit 6adb3c3

Browse files
committed
Fix the encoding option and line splitting in transforms
1 parent 4a0a121 commit 6adb3c3

14 files changed

Lines changed: 394 additions & 40 deletions

File tree

‎docs/api.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -988,6 +988,8 @@ If it outputs binary data instead, this should be either:
988988

989989
The output is available with [`result.stdout`](#resultstdout), [`result.stderr`](#resultstderr) and [`result.stdio`](#resultstdio).
990990

991+
String input passed with the [`input`](#optionsinput) or [`stdin`](#optionsstdin) option is always encoded with UTF-8, whatever this option is. With a text encoding, [`stdin` transforms](transform.md) also decode their input with UTF-8.
992+
991993
[More info.](binary.md)
992994

993995
### options.lines

‎docs/binary.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ const transform = function * (data) {
6161
await execa({stdout: {transform, binary: true}})`zip -r - input.txt`;
6262
```
6363

64+
> [!NOTE]
65+
> With the [`binary`](api.md#transformoptionsbinary) transform option, a `string` a transform yields is always converted to bytes using UTF-8, since the transform deals with raw bytes. Therefore, if the [`encoding`](api.md#optionsencoding) option is a different text encoding like `'utf16le'`, those bytes are then decoded with that encoding, which corrupts the text. Either `yield` an `Uint8Array` instead, or do not use the `binary` transform option.
66+
6467
## Streams
6568

6669
[Streams produced](streams.md#converting-a-subprocess-to-a-stream) by [`subprocess.readable()`](api.md#subprocessreadablereadableoptions) and [`subprocess.duplex()`](api.md#subprocessduplexduplexoptions) are binary by default, which means they iterate over arbitrary [`Buffer`](https://nodejs.org/api/buffer.html#class-buffer) chunks. However, if the [`binary`](api.md#readableoptionsbinary) option is `false`, they iterate over line strings instead, and the stream is [in object mode](https://nodejs.org/api/stream.html#object-mode).

‎lib/arguments/encoding-option.js‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ const TEXT_ENCODINGS = new Set(['utf8', 'utf16le']);
1919
export const BINARY_ENCODINGS = new Set(['buffer', 'hex', 'base64', 'base64url', 'latin1', 'ascii']);
2020
const ENCODINGS = TEXT_ENCODINGS.union(BINARY_ENCODINGS);
2121

22+
/*
23+
Encoding to use when converting a transform's string to bytes.
24+
Text encodings describe how text is represented as bytes, so those must be used, otherwise the string would be decoded with a different encoding than the one it was encoded with, which corrupts it.
25+
Binary encodings instead describe how bytes are serialized as text, so the string's UTF-8 bytes are used. The same applies with the `binary` transform option, since the transform then deals with raw bytes.
26+
*/
27+
export const getStringEncoding = (binary, encoding) => binary || !TEXT_ENCODINGS.has(encoding) ? 'utf8' : encoding;
28+
29+
// Text encodings only apply to the subprocess' output. Its input is always text encoded with UTF-8, so input transforms decode and encode strings with UTF-8 too, whichever text encoding the `encoding` option uses.
30+
export const getInputEncoding = encoding => TEXT_ENCODINGS.has(encoding) ? 'utf8' : encoding;
31+
2232
const getCorrectEncoding = encoding => {
2333
if (encoding === null) {
2434
return 'buffer';

‎lib/io/input-sync.js‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {runGeneratorsSync} from '../transform/generator.js';
22
import {joinToUint8Array, isUint8Array} from '../utils/uint-array.js';
33
import {TYPE_TO_MESSAGE} from '../stdio/type.js';
4+
import {getInputEncoding} from '../arguments/encoding-option.js';
45

56
// Apply `stdin`/`input`/`inputFile` options, before spawning, in sync mode, by converting it to the `input` option
67
export const addInputOptionsSync = (fileDescriptors, options) => {
@@ -26,12 +27,13 @@ const addInputOptionSync = (fileDescriptors, fdNumber, options) => {
2627
}
2728

2829
const allContents = allStdioItems.map(({contents}) => contents);
29-
const transformedContents = allContents.map(contents => applySingleInputGeneratorsSync(contents, stdioItems));
30+
const transformedContents = allContents.map(contents => applySingleInputGeneratorsSync(contents, stdioItems, options.encoding));
3031
options.input = joinToUint8Array(transformedContents);
3132
};
3233

33-
const applySingleInputGeneratorsSync = (contents, stdioItems) => {
34-
const newContents = runGeneratorsSync(contents, stdioItems, 'utf8', true);
34+
const applySingleInputGeneratorsSync = (contents, stdioItems, encoding) => {
35+
// Input transforms use UTF-8 with text encodings, since those only apply to the output
36+
const newContents = runGeneratorsSync(contents, stdioItems, getInputEncoding(encoding), true);
3537
validateSerializable(newContents);
3638
return joinToUint8Array(newContents);
3739
};

‎lib/stdio/handle-async.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {createReadStream, createWriteStream} from 'node:fs';
22
import {Buffer} from 'node:buffer';
33
import {Readable, Writable, Duplex} from 'node:stream';
44
import {generatorToStream} from '../transform/generator.js';
5+
import {getInputEncoding} from '../arguments/encoding-option.js';
56
import {handleStdio} from './handle.js';
67
import {TYPE_TO_MESSAGE} from './type.js';
78

@@ -28,9 +29,14 @@ const addProperties = {
2829
native() {},
2930
};
3031

32+
// Input transforms use UTF-8 with text encodings, since those only apply to the output
33+
const generatorToInputStream = (stdioItem, {encoding}) => generatorToStream(stdioItem, {encoding: getInputEncoding(encoding)});
34+
3135
const addPropertiesAsync = {
3236
input: {
3337
...addProperties,
38+
generator: generatorToInputStream,
39+
asyncGenerator: generatorToInputStream,
3440
fileUrl: ({value}) => ({stream: createReadStream(value)}),
3541
filePath: ({value: {file}}) => ({stream: createReadStream(file)}),
3642
webStream: ({value}) => ({stream: Readable.fromWeb(value)}),

‎lib/transform/encoding-transform.js‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {Buffer} from 'node:buffer';
22
import {StringDecoder} from 'node:string_decoder';
3+
import {getStringEncoding} from '../arguments/encoding-option.js';
34
import {isUint8Array, bufferToUint8Array} from '../utils/uint-array.js';
45

56
/*
@@ -49,3 +50,17 @@ const encodingStringFinal = function * (stringDecoder) {
4950
yield lastChunk;
5051
}
5152
};
53+
54+
/*
55+
Once the user's transform has run, its string chunks are converted to bytes: by `Transform.push()` with the asynchronous methods, and by `joinToString()` with the synchronous ones. Both use UTF-8, yet the result is then decoded with the `encoding` option, which would corrupt the text when that option is a different text encoding.
56+
Therefore, those chunks are converted using the `encoding` option beforehand.
57+
With the `binary` transform option, they are converted with UTF-8 even when the `encoding` option is another text encoding. Otherwise, with synchronous methods, the next transform would receive the string itself, while with asynchronous methods it receives those UTF-8 bytes decoded with the `encoding` option.
58+
When the `encoding` option is UTF-8, strings round-trip unchanged, so there is nothing to do.
59+
*/
60+
export const getStringToBytesGenerator = (binary, encoding, skipped) => skipped || encoding === 'utf8'
61+
? undefined
62+
: {transform: stringToBytesGenerator.bind(undefined, getStringEncoding(binary, encoding))};
63+
64+
const stringToBytesGenerator = function * (stringEncoding, chunk) {
65+
yield typeof chunk === 'string' ? bufferToUint8Array(Buffer.from(chunk, stringEncoding)) : chunk;
66+
};

‎lib/transform/generator.js‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import {Transform, getDefaultHighWaterMark} from 'node:stream';
2+
import {getStringEncoding} from '../arguments/encoding-option.js';
23
import {isAsyncGenerator} from '../stdio/type.js';
34
import {getSplitLinesGenerator, getAppendNewlineGenerator} from './split.js';
45
import {getValidateTransformInput, getValidateTransformReturn} from './validate.js';
5-
import {getEncodingTransformGenerator} from './encoding-transform.js';
6+
import {getEncodingTransformGenerator, getStringToBytesGenerator} from './encoding-transform.js';
67
import {
78
pushChunks,
89
transformChunk,
@@ -37,7 +38,13 @@ Transform an array of generator functions into a `Transform` stream.
3738
*/
3839
export const generatorToStream = ({
3940
value,
40-
value: {transform, final, writableObjectMode, readableObjectMode},
41+
value: {
42+
transform,
43+
final,
44+
binary,
45+
writableObjectMode,
46+
readableObjectMode,
47+
},
4148
optionName,
4249
}, {encoding}) => {
4350
const state = {};
@@ -60,6 +67,8 @@ export const generatorToStream = ({
6067
writableHighWaterMark: getDefaultHighWaterMark(writableObjectMode),
6168
readableObjectMode,
6269
readableHighWaterMark: getDefaultHighWaterMark(readableObjectMode),
70+
// A string written to the stream is converted to bytes with this encoding, which `getEncodingTransformGenerator()` then decodes with the `encoding` option
71+
defaultEncoding: getStringEncoding(binary, encoding),
6372
transform(chunk, encoding, done) {
6473
transformMethod([chunk, generators, 0], this, done);
6574
},
@@ -102,6 +111,8 @@ const addInternalGenerators = (
102111
preserveNewlines,
103112
readableObjectMode,
104113
state,
114+
encoding,
105115
}),
116+
getStringToBytesGenerator(binary, encoding, readableObjectMode),
106117
].filter(Boolean);
107118
};

‎lib/transform/split.js‎

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import {getStringEncoding} from '../arguments/encoding-option.js';
2+
13
// Split chunks line-wise for generators passed to the `std*` options
24
export const getSplitLinesGenerator = (binary, preserveNewlines, skipped, state) => binary || skipped
35
? undefined
@@ -36,15 +38,16 @@ const splitGenerator = function * (state, preserveNewlines, chunk) {
3638
continue;
3739
}
3840

39-
const newlineLength = getNewlineLength(chunk, end, preserveNewlines, state);
40-
let line = chunk.slice(start + 1, end + 1 - newlineLength);
41+
// The `\r` of a `\r\n` sequence is stripped further below, once `previousChunks` has been prepended.
42+
// Otherwise, a `\r\n` sequence split across two chunks would not be detected.
43+
let line = chunk.slice(start + 1, preserveNewlines ? end + 1 : end);
4144

4245
if (previousChunks.length > 0) {
4346
line = concatString(previousChunks, line);
4447
previousChunks = '';
4548
}
4649

47-
yield line;
50+
yield preserveNewlines ? line : stripCarriageReturn(line, state);
4851
start = end;
4952
}
5053

@@ -55,13 +58,9 @@ const splitGenerator = function * (state, preserveNewlines, chunk) {
5558
state.previousChunks = previousChunks;
5659
};
5760

58-
const getNewlineLength = (chunk, end, preserveNewlines, state) => {
59-
if (preserveNewlines) {
60-
return 0;
61-
}
62-
63-
state.isWindowsNewline = end !== 0 && chunk[end - 1] === '\r';
64-
return state.isWindowsNewline ? 2 : 1;
61+
const stripCarriageReturn = (line, state) => {
62+
state.isWindowsNewline = line.endsWith('\r');
63+
return state.isWindowsNewline ? line.slice(0, -1) : line;
6564
};
6665

6766
const linesFinal = function * ({previousChunks}) {
@@ -72,29 +71,36 @@ const linesFinal = function * ({previousChunks}) {
7271

7372
// Unless `preserveNewlines: true` is used, we strip the newline of each line.
7473
// This re-adds them after the user `transform` code has run.
75-
export const getAppendNewlineGenerator = ({binary, preserveNewlines, readableObjectMode, state}) => binary || preserveNewlines || readableObjectMode
74+
// The re-added newline must be encoded with the `encoding` option when the chunk is bytes: a UTF-8 newline would misalign `utf16le` bytes.
75+
export const getAppendNewlineGenerator = ({binary, preserveNewlines, readableObjectMode, state, encoding}) => binary || preserveNewlines || readableObjectMode
7676
? undefined
77-
: {transform: appendNewlineGenerator.bind(undefined, state)};
77+
: {transform: appendNewlineGenerator.bind(undefined, state, getStringEncoding(binary, encoding))};
7878

79-
const appendNewlineGenerator = function * ({isWindowsNewline = false}, chunk) {
80-
const {unixNewline, windowsNewline, LF, concatBytes} = typeof chunk === 'string' ? linesStringInfo : linesUint8ArrayInfo;
79+
const appendNewlineGenerator = function * ({isWindowsNewline = false}, stringEncoding, chunk) {
80+
const {unixNewline, windowsNewline, concat} = typeof chunk === 'string'
81+
? linesStringInfo
82+
: LINES_BYTES_INFO[stringEncoding];
8183

82-
if (chunk.at(-1) === LF) {
84+
if (endsWithNewline(chunk, unixNewline)) {
8385
yield chunk;
8486
return;
8587
}
8688

8789
const newline = isWindowsNewline ? windowsNewline : unixNewline;
88-
yield concatBytes(chunk, newline);
90+
yield concat(chunk, newline);
8991
};
9092

93+
const endsWithNewline = (chunk, unixNewline) => typeof chunk === 'string'
94+
? chunk.endsWith(unixNewline)
95+
: chunk.length >= unixNewline.length
96+
&& chunk.subarray(-unixNewline.length).every((byte, index) => byte === unixNewline[index]);
97+
9198
const concatString = (firstChunk, secondChunk) => `${firstChunk}${secondChunk}`;
9299

93100
const linesStringInfo = {
94101
windowsNewline: '\r\n',
95102
unixNewline: '\n',
96-
LF: '\n',
97-
concatBytes: concatString,
103+
concat: concatString,
98104
};
99105

100106
const concatUint8Array = (firstChunk, secondChunk) => {
@@ -104,9 +110,17 @@ const concatUint8Array = (firstChunk, secondChunk) => {
104110
return chunk;
105111
};
106112

107-
const linesUint8ArrayInfo = {
108-
windowsNewline: new Uint8Array([0x0D, 0x0A]),
109-
unixNewline: new Uint8Array([0x0A]),
110-
LF: 0x0A,
111-
concatBytes: concatUint8Array,
113+
// Only indexed with text encodings, since the `binary` option and binary encodings disable this generator
114+
const LINES_BYTES_INFO = {
115+
__proto__: null,
116+
utf8: {
117+
windowsNewline: new Uint8Array([0x0D, 0x0A]),
118+
unixNewline: new Uint8Array([0x0A]),
119+
concat: concatUint8Array,
120+
},
121+
utf16le: {
122+
windowsNewline: new Uint8Array([0x0D, 0x00, 0x0A, 0x00]),
123+
unixNewline: new Uint8Array([0x0A, 0x00]),
124+
concat: concatUint8Array,
125+
},
112126
};

‎test/helpers/lines.js‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,7 @@ export const simpleFullUtf16Inverted = simpleFullBuffer.toString('utf16le');
2020
const simpleFullUtf16Buffer = Buffer.from(simpleFull, 'utf16le');
2121
export const simpleFullUtf16Uint8Array = new Uint8Array(simpleFullUtf16Buffer);
2222
export const simpleFullEnd = `${simpleFull}\n`;
23-
const simpleFullEndBuffer = Buffer.from(simpleFullEnd);
2423
export const simpleFullEndChunks = [simpleFullEnd];
25-
export const simpleFullEndUtf16Inverted = simpleFullEndBuffer.toString('utf16le');
2624
export const simpleLines = ['aaa\n', 'bbb\n', 'ccc'];
2725
export const simpleFullEndLines = ['aaa\n', 'bbb\n', 'ccc\n'];
2826
export const noNewlinesFull = 'aaabbbccc';

‎test/io/input-option.js‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {Buffer} from 'node:buffer';
12
import {Writable} from 'node:stream';
23
import test from 'ava';
34
import {execa, execaSync} from '../../index.js';
@@ -16,6 +17,7 @@ import {
1617
foobarUint16Array,
1718
foobarDataView,
1819
} from '../helpers/input.js';
20+
import {noopGenerator} from '../helpers/generator.js';
1921

2022
setFixtureDirectory();
2123

@@ -67,3 +69,58 @@ test('input option cannot be 0 - sync', testInvalidInput, 0, execaSync);
6769
test('input option cannot be false - sync', testInvalidInput, false, execaSync);
6870
test('input option cannot be null - sync', testInvalidInput, null, execaSync);
6971
test('input option cannot be a non-Readable stream - sync', testInvalidInput, new Writable(), execaSync);
72+
73+
/*
74+
String input is encoded with the `encoding` option, so it round-trips with subprocesses echoing it back.
75+
This must not depend on whether a `stdin` transform happens to be present.
76+
*/
77+
const nonAsciiString = '🦄🦄';
78+
79+
const testStringEncoding = async (t, options, execaMethod) => {
80+
const {stdout} = await execaMethod('stdin.js', {...options, input: nonAsciiString, encoding: 'utf16le'});
81+
t.is(stdout, nonAsciiString);
82+
};
83+
84+
test('input option string is encoded with encoding "utf16le" without transforms', testStringEncoding, {}, execa);
85+
test('input option string is encoded with encoding "utf16le" without transforms - sync', testStringEncoding, {}, execaSync);
86+
test('input option string is encoded with encoding "utf16le" with transforms', testStringEncoding, {stdin: noopGenerator()}, execa);
87+
test('input option string is encoded with encoding "utf16le" with transforms - sync', testStringEncoding, {stdin: noopGenerator()}, execaSync);
88+
89+
/*
90+
Sources other than the `input` option also pass strings to the subprocess, which must be encoded with the `encoding` option too.
91+
*/
92+
const testIterableInputEncoding = async (t, execaMethod) => {
93+
const {stdout} = await execaMethod('stdin.js', {
94+
stdin: [[`${nonAsciiString}aaa\n`], [`${nonAsciiString}bbb\n`]],
95+
encoding: 'utf16le',
96+
stripFinalNewline: false,
97+
});
98+
t.is(stdout, `${nonAsciiString}aaa\n${nonAsciiString}bbb\n`);
99+
};
100+
101+
test('Iterable string input is encoded with encoding "utf16le"', testIterableInputEncoding, execa);
102+
test('Iterable string input is encoded with encoding "utf16le" - sync', testIterableInputEncoding, execaSync);
103+
104+
const testAsyncIterableInputEncoding = async t => {
105+
const chunks = async function * () {
106+
yield nonAsciiString;
107+
};
108+
109+
const {stdout} = await execa('stdin.js', {stdin: [chunks()], encoding: 'utf16le'});
110+
t.is(stdout, nonAsciiString);
111+
};
112+
113+
test('Async iterable string input is encoded with encoding "utf16le"', testAsyncIterableInputEncoding);
114+
115+
/*
116+
The `encoding` option does not change how string input is encoded when it is a binary encoding: those describe how output bytes are serialized, so input keeps using UTF-8.
117+
*/
118+
const testBinaryEncodingInput = async (t, encoding, execaMethod) => {
119+
const {stdout} = await execaMethod('stdin.js', {input: nonAsciiString, encoding});
120+
t.is(stdout, Buffer.from(nonAsciiString).toString(encoding));
121+
};
122+
123+
test('String input stays UTF-8 with encoding "latin1"', testBinaryEncodingInput, 'latin1', execa);
124+
test('String input stays UTF-8 with encoding "latin1" - sync', testBinaryEncodingInput, 'latin1', execaSync);
125+
test('String input stays UTF-8 with encoding "hex"', testBinaryEncodingInput, 'hex', execa);
126+
test('String input stays UTF-8 with encoding "hex" - sync', testBinaryEncodingInput, 'hex', execaSync);

0 commit comments

Comments
 (0)