@@ -1320,6 +1320,73 @@ done
13201320 expect ( asyncOut . trim ( ) . split ( "\n" ) ) . toEqual ( expected ) ;
13211321} ) ;
13221322
1323+ // node:child_process validates its options as node 26.3.0 does. The errors below are node's.
1324+ type ValidationError = { name : string ; code : string ; message : string } ;
1325+ function invalidArgType ( name : string , type : string , received : string ) : ValidationError {
1326+ return {
1327+ name : "TypeError" ,
1328+ code : "ERR_INVALID_ARG_TYPE" ,
1329+ message : `The "${ name } " ${ name . includes ( "." ) ? "property" : "argument" } must be of type ${ type } . Received ${ received } ` ,
1330+ } ;
1331+ }
1332+ function outOfRange ( name : string , range : string , received : string ) : ValidationError {
1333+ return {
1334+ name : "RangeError" ,
1335+ code : "ERR_OUT_OF_RANGE" ,
1336+ message : `The value of "${ name } " is out of range. It must be ${ range } . Received ${ received } ` ,
1337+ } ;
1338+ }
1339+ const notNumbers : [ value : unknown , received : string ] [ ] = [
1340+ [ "0" , "type string ('0')" ] ,
1341+ [ true , "type boolean (true)" ] ,
1342+ [ { } , "an instance of Object" ] ,
1343+ [ [ ] , "an instance of Array" ] ,
1344+ [ 0n , "type bigint (0n)" ] ,
1345+ [ Symbol ( "s" ) , "type symbol (Symbol(s))" ] ,
1346+ [ function fn ( ) { } , "function fn" ] ,
1347+ ] ;
1348+
1349+ // Every function export validates its options before it starts a process.
1350+ const functionExports : Record < string , ( options : any ) => unknown > = {
1351+ spawn : options => spawn ( bunExe ( ) , [ "-e" , "0" ] , options ) ,
1352+ spawnSync : options => spawnSync ( bunExe ( ) , [ "-e" , "0" ] , options ) ,
1353+ execFile : options => execFile ( bunExe ( ) , [ "-e" , "0" ] , options , ( ) => { } ) ,
1354+ execFileSync : options => execFileSync ( bunExe ( ) , [ "-e" , "0" ] , options ) ,
1355+ exec : options => exec ( "exit 0" , options , ( ) => { } ) ,
1356+ execSync : options => execSync ( "exit 0" , options ) ,
1357+ fork : options => fork ( path . join ( import . meta. dir , "fixtures" , "child-process-echo-argv.js" ) , options ) ,
1358+ } ;
1359+
1360+ /** The error that `call()` throws. A child that got past the validation is killed. */
1361+ function thrown ( call : ( ) => unknown ) : ValidationError | undefined {
1362+ try {
1363+ const child = call ( ) ;
1364+ if ( child instanceof ChildProcess ) {
1365+ child . on ( "error" , ( ) => { } ) ;
1366+ child . kill ( ) ;
1367+ }
1368+ } catch ( e : any ) {
1369+ return { name : e . name , code : e . code , message : e . message } ;
1370+ }
1371+ }
1372+ function thrownBy ( fn : string , options : object ) {
1373+ return thrown ( ( ) => functionExports [ fn ] ( options ) ) ;
1374+ }
1375+
1376+ /** Expects `fn` to throw `error` for each `[value, error]` row, with `value` as the option. */
1377+ function expectErrors ( fn : string , option : string , rows : [ value : unknown , error : ValidationError ] [ ] ) {
1378+ expect ( rows . map ( ( [ value ] ) => thrownBy ( fn , { [ option ] : value } ) ) ) . toEqual ( rows . map ( ( [ , error ] ) => error ) ) ;
1379+ }
1380+
1381+ /** Expects each value to pass the validation: spawnSync() then gets as far as the missing command. */
1382+ function expectAccepted ( option : string , values : unknown [ ] ) {
1383+ const codes = values . map ( value => {
1384+ const { error } = spawnSync ( "bun-test-no-such-command" , { [ option ] : value } ) ;
1385+ return ( error as NodeJS . ErrnoException | undefined ) ?. code ;
1386+ } ) ;
1387+ expect ( codes ) . toEqual ( values . map ( ( ) => "ENOENT" ) ) ;
1388+ }
1389+
13231390describe ( "uid/gid options" , ( ) => {
13241391 const isRoot = process . getuid ?.( ) === 0 ;
13251392 // 65534 is "nobody" on every Linux distro and on macOS.
@@ -1418,6 +1485,125 @@ console.log(JSON.stringify({ uid: process.getuid(), threwCode: thrown?.code, thr
14181485 const r = spawnSync ( "cmd.exe" , [ "/c" , "exit 0" ] , { gid : 0 } ) ;
14191486 expect ( ( r . error as NodeJS . ErrnoException | undefined ) ?. code ) . toBe ( "ENOTSUP" ) ;
14201487 } ) ;
1488+
1489+ // The check runs before a process exists, so it needs no privilege and is the same on every platform.
1490+ describe . each ( [ "uid" , "gid" ] ) ( "options.%s" , key => {
1491+ const name = `options.${ key } ` ;
1492+ const int32 = ">= -2147483648 && <= 2147483647" ;
1493+ const rejected : [ value : unknown , error : ValidationError ] [ ] = [
1494+ [ NaN , outOfRange ( name , "an integer" , "NaN" ) ] ,
1495+ [ 1.5 , outOfRange ( name , "an integer" , "1.5" ) ] ,
1496+ [ Infinity , outOfRange ( name , "an integer" , "Infinity" ) ] ,
1497+ [ - Infinity , outOfRange ( name , "an integer" , "-Infinity" ) ] ,
1498+ [ 2 ** 31 , outOfRange ( name , int32 , "2147483648" ) ] ,
1499+ [ 2 ** 32 , outOfRange ( name , int32 , "4294967296" ) ] ,
1500+ [ - ( 2 ** 31 ) - 1 , outOfRange ( name , int32 , "-2147483649" ) ] ,
1501+ [ 2 ** 63 , outOfRange ( name , int32 , "9_223_372_036_854_776_000" ) ] ,
1502+ ...notNumbers . map ( ( [ value , received ] ) : [ unknown , ValidationError ] => [
1503+ value ,
1504+ invalidArgType ( name , "number" , received ) ,
1505+ ] ) ,
1506+ ] ;
1507+
1508+ it . each ( Object . keys ( functionExports ) ) ( "%s() throws node's error for a value that is not an int32" , fn => {
1509+ expectErrors ( fn , key , rejected ) ;
1510+ } ) ;
1511+
1512+ it ( "is not converted to a number, and every int32, null and undefined pass" , ( ) => {
1513+ let conversions = 0 ;
1514+ const object = { valueOf : ( ) => ( conversions ++ , 0 ) } ;
1515+ expect ( thrownBy ( "spawnSync" , { [ key ] : object } ) ) . toEqual ( invalidArgType ( name , "number" , "an instance of Object" ) ) ;
1516+ expect ( conversions ) . toBe ( 0 ) ;
1517+
1518+ expectAccepted ( key , [ - ( 2 ** 31 ) , - 1 , - 0 , 0 , 2 ** 31 - 1 , null , undefined ] ) ;
1519+
1520+ // node's validateInt32 accepts -0 too. node then rejects it in C++ (spawnSync throws, spawn aborts).
1521+ // Bun uses id 0.
1522+ const run = ( value : number ) => {
1523+ const command = isWindows ? "cmd.exe" : "id" ;
1524+ const args = isWindows ? [ "/c" , "exit 0" ] : [ key === "uid" ? "-u" : "-g" ] ;
1525+ const { status, stdout, error } = spawnSync ( command , args , { [ key ] : value , encoding : "utf8" } ) ;
1526+ return { status, stdout, error : ( error as NodeJS . ErrnoException | undefined ) ?. code } ;
1527+ } ;
1528+ expect ( run ( - 0 ) ) . toEqual ( run ( 0 ) ) ;
1529+ } ) ;
1530+ } ) ;
1531+ } ) ;
1532+
1533+ describe ( "timeout, maxBuffer, windowsHide and cwd options" , ( ) => {
1534+ const safeInteger = ">= 0 && <= 9007199254740991" ;
1535+ const timeout : [ value : unknown , error : ValidationError ] [ ] = [
1536+ [ NaN , outOfRange ( "timeout" , "an integer" , "NaN" ) ] ,
1537+ [ 1.5 , outOfRange ( "timeout" , "an integer" , "1.5" ) ] ,
1538+ [ Infinity , outOfRange ( "timeout" , "an integer" , "Infinity" ) ] ,
1539+ [ - Infinity , outOfRange ( "timeout" , "an integer" , "-Infinity" ) ] ,
1540+ [ - 1 , outOfRange ( "timeout" , safeInteger , "-1" ) ] ,
1541+ [ 2 ** 53 , outOfRange ( "timeout" , safeInteger , "9_007_199_254_740_992" ) ] ,
1542+ ...notNumbers . map ( ( [ value , received ] ) : [ unknown , ValidationError ] => [
1543+ value ,
1544+ invalidArgType ( "timeout" , "number" , received ) ,
1545+ ] ) ,
1546+ ] ;
1547+ const maxBuffer : [ value : unknown , error : ValidationError ] [ ] = [
1548+ [ NaN , outOfRange ( "options.maxBuffer" , ">= 0" , "NaN" ) ] ,
1549+ [ - 1 , outOfRange ( "options.maxBuffer" , ">= 0" , "-1" ) ] ,
1550+ [ - Infinity , outOfRange ( "options.maxBuffer" , ">= 0" , "-Infinity" ) ] ,
1551+ ...notNumbers . map ( ( [ value , received ] ) : [ unknown , ValidationError ] => [
1552+ value ,
1553+ invalidArgType ( "options.maxBuffer" , "number" , received ) ,
1554+ ] ) ,
1555+ ] ;
1556+
1557+ it . each ( Object . keys ( functionExports ) ) ( "%s() throws node's error for an invalid timeout" , fn => {
1558+ expectErrors ( fn , "timeout" , timeout ) ;
1559+ } ) ;
1560+
1561+ // spawn() and fork() have no maxBuffer.
1562+ it . each ( [ "spawnSync" , "execFile" , "execFileSync" , "exec" , "execSync" ] ) (
1563+ "%s() throws node's error for an invalid maxBuffer" ,
1564+ fn => {
1565+ expectErrors ( fn , "maxBuffer" , maxBuffer ) ;
1566+ } ,
1567+ ) ;
1568+
1569+ it ( "accepts every integer timeout from 0 to 2 ** 53 - 1, and spawnSync() throws above that" , ( ) => {
1570+ expectAccepted ( "timeout" , [ 0 , - 0 , 1 , 2 ** 31 - 1 , 2 ** 31 , 2 ** 53 - 1 , null , undefined ] ) ;
1571+ // Bun.spawnSync rejected this value, and spawnSync() returned that as `result.error`. node throws.
1572+ expect ( thrownBy ( "spawnSync" , { timeout : 2 ** 53 } ) ) . toEqual (
1573+ outOfRange ( "timeout" , safeInteger , "9_007_199_254_740_992" ) ,
1574+ ) ;
1575+ } ) ;
1576+
1577+ it ( "reports the same option as node when two options are invalid" , ( ) => {
1578+ const options = { uid : NaN , timeout : "x" } ;
1579+ // execFile() validates the timeout before spawn() validates the uid. spawnSync() validates the uid first.
1580+ expect ( thrownBy ( "execFile" , options ) ) . toEqual ( invalidArgType ( "timeout" , "number" , "type string ('x')" ) ) ;
1581+ expect ( thrownBy ( "spawnSync" , options ) ) . toEqual ( outOfRange ( "options.uid" , "an integer" , "NaN" ) ) ;
1582+ } ) ;
1583+
1584+ it ( "exec() and execFile() coerce windowsHide and windowsVerbatimArguments, the other functions validate them" , async ( ) => {
1585+ const options : any = { windowsHide : "yes" , windowsVerbatimArguments : 1 } ;
1586+ // node's execFile() passes `!!options.windowsHide` to spawn(), so these two run the child.
1587+ expect ( await promisify ( execFile ) ( bunExe ( ) , [ "-e" , "0" ] , options ) ) . toEqual ( { stdout : "" , stderr : "" } ) ;
1588+ expect ( await promisify ( exec ) ( "exit 0" , options ) ) . toEqual ( { stdout : "" , stderr : "" } ) ;
1589+
1590+ for ( const fn of [ "spawn" , "spawnSync" , "execFileSync" , "execSync" , "fork" ] ) {
1591+ expect ( thrownBy ( fn , options ) ) . toEqual ( invalidArgType ( "options.windowsHide" , "boolean" , "type string ('yes')" ) ) ;
1592+ }
1593+ } ) ;
1594+
1595+ it ( "rejects a null byte in cwd and in the module path of fork() with node's text" , ( ) => {
1596+ const nullByte = ( subject : string , received : string ) => ( {
1597+ name : "TypeError" ,
1598+ code : "ERR_INVALID_ARG_VALUE" ,
1599+ message : `The ${ subject } must be a string, Uint8Array, or URL without null bytes. Received ${ received } ` ,
1600+ } ) ;
1601+ expect ( thrownBy ( "spawn" , { cwd : "a\0b" } ) ) . toEqual ( nullByte ( "property 'options.cwd'" , "'a\\x00b'" ) ) ;
1602+ expect ( thrownBy ( "spawnSync" , { cwd : Buffer . from ( "a\0b" ) } ) ) . toEqual (
1603+ nullByte ( "property 'options.cwd'" , "<Buffer 61 00 62>" ) ,
1604+ ) ;
1605+ expect ( thrown ( ( ) => fork ( "a\0b" ) ) ) . toEqual ( nullByte ( "argument 'modulePath'" , "'a\\x00b'" ) ) ;
1606+ } ) ;
14211607} ) ;
14221608
14231609// Regression: Bun registered the stdout/stderr poll immediately, so the native
0 commit comments