Repository: sindresorhus/execa
Pull request #668 (2024-01-09 19:02:58)

Validate `input` option's type
This PR improves the validation of the `input` option. Currently, if the wrong type is passed, the error message is confusing. This enforces that the `input` option is a `string`, `Uint8Array` or `Readable` Node.js stream.


Patch:
diff --git a/index.js b/index.js
index 8bc872c..b702988 100644
--- a/index.js
+++ b/index.js
@@ -16,6 +16,7 @@ import {getSpawnedResult, makeAllStream} from './lib/stream.js';
 import {mergePromise} from './lib/promise.js';
 import {joinCommand, parseCommand, parseTemplates, getEscapedCommand} from './lib/command.js';
 import {logCommand, verboseDefault} from './lib/verbose.js';
+import {bufferToUint8Array} from './lib/stdio/utils.js';
 
 const DEFAULT_MAX_BUFFER = 1000 * 1000 * 100;
 
@@ -81,7 +82,7 @@ const handleArguments = (rawFile, rawArgs, rawOptions = {}) => {
 
 const handleOutputSync = (options, value, error) => {
 	if (Buffer.isBuffer(value)) {
-		value = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
+		value = bufferToUint8Array(value);
 	}
 
 	return handleOutput(options, value, error);
diff --git a/lib/stdio/async.js b/lib/stdio/async.js
index f09079b..e9e30ae 100644
--- a/lib/stdio/async.js
+++ b/lib/stdio/async.js
@@ -12,7 +12,8 @@ const addPropertiesAsync = {
 		filePath: ({value}) => ({value: createReadStream(value)}),
 		webStream: ({value}) => ({value: Readable.fromWeb(value)}),
 		iterable: ({value}) => ({value: Readable.from(value)}),
-		stringOrBuffer: ({value}) => ({value: Readable.from(ArrayBuffer.isView(value) ? Buffer.from(value) : value)}),
+		string: ({value}) => ({value: Readable.from(value)}),
+		uint8Array: ({value}) => ({value: Readable.from(Buffer.from(value))}),
 	},
 	output: {
 		filePath: ({value}) => ({value: createWriteStream(value)}),
diff --git a/lib/stdio/direction.js b/lib/stdio/direction.js
index c3b4e96..4ecb169 100644
--- a/lib/stdio/direction.js
+++ b/lib/stdio/direction.js
@@ -26,7 +26,7 @@ const getStreamDirection = stdioStream => KNOWN_DIRECTIONS[stdioStream.index] ??
 // `stdin`/`stdout`/`stderr` have a known direction
 const KNOWN_DIRECTIONS = ['input', 'output', 'output'];
 
-// `stringOrBuffer` type always applies to `stdin`, i.e. does not need to be handled here
+// `string` and `uint8Array` types always applies to `stdin`, i.e. does not need to be handled here
 const guessStreamDirection = {
 	filePath: () => undefined,
 	iterable: () => 'input',
diff --git a/lib/stdio/input.js b/lib/stdio/input.js
index b94ff6f..e452262 100644
--- a/lib/stdio/input.js
+++ b/lib/stdio/input.js
@@ -1,4 +1,5 @@
-import {isStream as isNodeStream} from 'is-stream';
+import {Buffer} from 'node:buffer';
+import {isReadableStream} from 'is-stream';
 
 // Append the `stdin` option with the `input` and `inputFile` options
 export const handleInputOptions = ({input, inputFile}) => [
@@ -6,14 +7,30 @@ export const handleInputOptions = ({input, inputFile}) => [
 	handleInputFileOption(inputFile),
 ].filter(Boolean);
 
-const handleInputOption = input => input && {
-	type: isNodeStream(input) ? 'nodeStream' : 'stringOrBuffer',
+const handleInputOption = input => input === undefined ? undefined : {
+	type: getType(input),
 	value: input,
 	optionName: 'input',
 	index: 0,
 };
 
-const handleInputFileOption = inputFile => inputFile && {
+const getType = input => {
+	if (isReadableStream(input)) {
+		return 'nodeStream';
+	}
+
+	if (typeof input === 'string') {
+		return 'string';
+	}
+
+	if (Object.prototype.toString.call(input) === '[object Uint8Array]' && !Buffer.isBuffer(input)) {
+		return 'uint8Array';
+	}
+
+	throw new Error('The `input` option must be a string, a Uint8Array or a Node.js Readable stream.');
+};
+
+const handleInputFileOption = inputFile => inputFile === undefined ? undefined : {
 	type: 'filePath',
 	value: inputFile,
 	optionName: 'inputFile',
diff --git a/lib/stdio/sync.js b/lib/stdio/sync.js
index 311c051..751878f 100644
--- a/lib/stdio/sync.js
+++ b/lib/stdio/sync.js
@@ -2,6 +2,7 @@ import {readFileSync, writeFileSync} from 'node:fs';
 import {isStream as isNodeStream} from 'is-stream';
 import {handleInput} from './handle.js';
 import {TYPE_TO_MESSAGE} from './type.js';
+import {bufferToUint8Array} from './utils.js';
 
 // Handle `input`, `inputFile`, `stdin`, `stdout` and `stderr` options, before spawning, in sync mode
 export const handleInputSync = options => {
@@ -24,7 +25,7 @@ const forbiddenIfSync = ({type, optionName}) => {
 
 const addPropertiesSync = {
 	input: {
-		filePath: ({value}) => ({value: readFileSync(value, 'utf8'), type: 'stringOrBuffer'}),
+		filePath: ({value}) => ({value: bufferToUint8Array(readFileSync(value)), type: 'uint8Array'}),
 		webStream: forbiddenIfSync,
 		nodeStream: forbiddenIfSync,
 		iterable: forbiddenIfSync,
@@ -39,17 +40,17 @@ const addPropertiesSync = {
 };
 
 const addInputOptionSync = (stdioStreams, options) => {
-	const inputs = stdioStreams.filter(({type}) => type === 'stringOrBuffer');
+	const inputs = stdioStreams.filter(({type}) => type === 'string' || type === 'uint8Array');
 	if (inputs.length === 0) {
 		return;
 	}
 
 	options.input = inputs.length === 1
 		? inputs[0].value
-		: inputs.map(({value}) => serializeInput(value)).join('');
+		: inputs.map(stdioStream => serializeInput(stdioStream)).join('');
 };
 
-const serializeInput = value => typeof value === 'string' ? value : new TextDecoder().decode(value);
+const serializeInput = ({type, value}) => type === 'string' ? value : new TextDecoder().decode(value);
 
 // Handle `input`, `inputFile`, `stdin`, `stdout` and `stderr` options, after spawning, in sync mode
 export const pipeOutputSync = (stdioStreams, result) => {
diff --git a/lib/stdio/type.js b/lib/stdio/type.js
index 47838a0..711e3ce 100644
--- a/lib/stdio/type.js
+++ b/lib/stdio/type.js
@@ -49,5 +49,6 @@ export const TYPE_TO_MESSAGE = {
 	nodeStream: 'a Node.js stream',
 	native: 'any value',
 	iterable: 'an iterable',
-	stringOrBuffer: 'a string or Uint8Array',
+	string: 'a string',
+	uint8Array: 'a Uint8Array',
 };
diff --git a/lib/stdio/utils.js b/lib/stdio/utils.js
new file mode 100644
index 0000000..1cda911
--- /dev/null
+++ b/lib/stdio/utils.js
@@ -0,0 +1,1 @@
+export const bufferToUint8Array = buffer => new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);


Test patch:
diff --git a/test/stdio/input.js b/test/stdio/input.js
index 4a53df4..7247915 100644
--- a/test/stdio/input.js
+++ b/test/stdio/input.js
@@ -1,3 +1,5 @@
+import {Buffer} from 'node:buffer';
+import {Writable} from 'node:stream';
 import test from 'ava';
 import {execa, execaSync, $} from '../../index.js';
 import {setFixtureDir} from '../helpers/fixtures-dir.js';
@@ -7,6 +9,10 @@ setFixtureDir();
 
 const textEncoder = new TextEncoder();
 const binaryFoobar = textEncoder.encode('foobar');
+const bufferFoobar = Buffer.from(binaryFoobar);
+const arrayBufferFoobar = binaryFoobar.buffer;
+const dataViewFoobar = new DataView(arrayBufferFoobar);
+const uint16ArrayFoobar = new Uint16Array(arrayBufferFoobar);
 
 const testInput = async (t, input, execaMethod) => {
 	const {stdout} = await execaMethod('stdin.js', {input});
@@ -25,3 +31,26 @@ const testInputScript = async (t, getExecaMethod) => {
 
 test('input option can be used with $', testInputScript, identity);
 test('input option can be used with $.sync', testInputScript, getScriptSync);
+
+const testInvalidInput = async (t, input, execaMethod) => {
+	t.throws(() => {
+		execaMethod('noop.js', {input});
+	}, {message: /a string, a Uint8Array/});
+};
+
+test('input option cannot be a Buffer', testInvalidInput, bufferFoobar, execa);
+test('input option cannot be an ArrayBuffer', testInvalidInput, arrayBufferFoobar, execa);
+test('input option cannot be a DataView', testInvalidInput, dataViewFoobar, execa);
+test('input option cannot be a Uint16Array', testInvalidInput, uint16ArrayFoobar, execa);
+test('input option cannot be 0', testInvalidInput, 0, execa);
+test('input option cannot be false', testInvalidInput, false, execa);
+test('input option cannot be null', testInvalidInput, null, execa);
+test('input option cannot be a non-Readable stream', testInvalidInput, new Writable(), execa);
+test('input option cannot be a Buffer - sync', testInvalidInput, bufferFoobar, execaSync);
+test('input option cannot be an ArrayBuffer - sync', testInvalidInput, arrayBufferFoobar, execaSync);
+test('input option cannot be a DataView - sync', testInvalidInput, dataViewFoobar, execaSync);
+test('input option cannot be a Uint16Array - sync', testInvalidInput, uint16ArrayFoobar, execaSync);
+test('input option cannot be 0 - sync', testInvalidInput, 0, execaSync);
+test('input option cannot be false - sync', testInvalidInput, false, execaSync);
+test('input option cannot be null - sync', testInvalidInput, null, execaSync);
+test('input option cannot be a non-Readable stream - sync', testInvalidInput, new Writable(), execaSync);