Ensure escaped string are parsed in NODE_OPTIONS (#65046)

## What?

Ensures paths that have spaces in them in `NODE_OPTIONS` are handled. An
example of that is VS Code's debugger which adds:

```
--require "/Applications/Visual Studio Code.app/Contents/Resources/app/extensions/ms-vscode.js-debug/src/bootloader.js"
```

Currently the output is cut off and causes: `invalid value for
NODE_OPTIONS (unterminated string)`.

Related issue: https://github.com/vercel/next.js/issues/63740

<!-- Thanks for opening a PR! Your contribution is much appreciated.
To make sure your PR is handled as smoothly as possible we request that
you follow the checklist sections below.
Choose the right checklist for the change(s) that you're making:

## For Contributors

### Improving Documentation

- Run `pnpm prettier-fix` to fix formatting issues before opening the
PR.
- Read the Docs Contribution Guide to ensure your contribution follows
the docs guidelines:
https://nextjs.org/docs/community/contribution-guide

### Adding or Updating Examples

- The "examples guidelines" are followed from our contributing doc
https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md
- Make sure the linting passes by running `pnpm build && pnpm lint`. See
https://github.com/vercel/next.js/blob/canary/contributing/repository/linting.md

### Fixing a bug

- Related issues linked using `fixes #number`
- Tests added. See:
https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs
- Errors have a helpful link attached, see
https://github.com/vercel/next.js/blob/canary/contributing.md

### Adding a feature

- Implements an existing feature request or RFC. Make sure the feature
request has been accepted for implementation before opening a PR. (A
discussion must be opened, see
https://github.com/vercel/next.js/discussions/new?category=ideas)
- Related issues/discussions are linked using `fixes #number`
- e2e tests added
(https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs)
- Documentation added
- Telemetry added. In case of a feature if it's used or not.
- Errors have a helpful link attached, see
https://github.com/vercel/next.js/blob/canary/contributing.md


## For Maintainers

- Minimal description (aim for explaining to someone not on the team to
understand the PR)
- When linking to a Slack thread, you might want to share details of the
conclusion
- Link both the Linear (Fixes NEXT-xxx) and the GitHub issues
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Closes NEXT-
Fixes #

-->


Closes NEXT-3226

---------

Co-authored-by: Wyatt Johnson <accounts+github@wyattjoh.ca>
Co-authored-by: Ethan Arrowood <ethan@arrowood.dev>
This commit is contained in:
Tim Neutkens 2024-04-26 10:56:33 +02:00 committed by GitHub
parent af304c5252
commit ae1fe5690b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 196 additions and 22 deletions

View file

@ -1,6 +1,8 @@
import { import {
getFormattedNodeOptionsWithoutInspect, getFormattedNodeOptionsWithoutInspect,
getParsedDebugAddress, getParsedDebugAddress,
formatNodeOptions,
tokenizeArgs,
} from './utils' } from './utils'
const originalNodeOptions = process.env.NODE_OPTIONS const originalNodeOptions = process.env.NODE_OPTIONS
@ -9,6 +11,48 @@ afterAll(() => {
process.env.NODE_OPTIONS = originalNodeOptions process.env.NODE_OPTIONS = originalNodeOptions
}) })
describe('tokenizeArgs', () => {
it('splits arguments by spaces', () => {
const result = tokenizeArgs('--spaces "thing with spaces" --normal 1234')
expect(result).toEqual([
'--spaces',
'thing with spaces',
'--normal',
'1234',
])
})
it('supports quoted values', () => {
const result = tokenizeArgs(
'--spaces "thing with spaces" --spacesAndQuotes "thing with \\"spaces\\"" --normal 1234'
)
expect(result).toEqual([
'--spaces',
'thing with spaces',
'--spacesAndQuotes',
'thing with "spaces"',
'--normal',
'1234',
])
})
})
describe('formatNodeOptions', () => {
it('wraps values with spaces in quotes', () => {
const result = formatNodeOptions({
spaces: 'thing with spaces',
spacesAndQuotes: 'thing with "spaces"',
normal: '1234',
})
expect(result).toBe(
'--spaces="thing with spaces" --spacesAndQuotes="thing with \\"spaces\\"" --normal=1234'
)
})
})
describe('getParsedDebugAddress', () => { describe('getParsedDebugAddress', () => {
it('supports the flag with an equal sign', () => { it('supports the flag with an equal sign', () => {
process.env.NODE_OPTIONS = '--inspect=1234' process.env.NODE_OPTIONS = '--inspect=1234'
@ -38,6 +82,26 @@ describe('getFormattedNodeOptionsWithoutInspect', () => {
expect(result).toBe('--other') expect(result).toBe('--other')
}) })
it('handles options with spaces', () => {
process.env.NODE_OPTIONS =
'--other --inspect --additional --spaces "/some/path with spaces"'
const result = getFormattedNodeOptionsWithoutInspect()
expect(result).toBe(
'--other --additional --spaces="/some/path with spaces"'
)
})
it('handles options with quotes', () => {
process.env.NODE_OPTIONS =
'--require "./file with spaces to-require-with-node-require-option.js"'
const result = getFormattedNodeOptionsWithoutInspect()
expect(result).toBe(
'--require="./file with spaces to-require-with-node-require-option.js"'
)
})
it('removes --inspect option with parameters', () => { it('removes --inspect option with parameters', () => {
process.env.NODE_OPTIONS = '--other --inspect=0.0.0.0:1234 --additional' process.env.NODE_OPTIONS = '--other --inspect=0.0.0.0:1234 --additional'
const result = getFormattedNodeOptionsWithoutInspect() const result = getFormattedNodeOptionsWithoutInspect()

View file

@ -16,7 +16,7 @@ const parseNodeArgs = (args: string[]) => {
// For the `NODE_OPTIONS`, we support arguments with values without the `=` // For the `NODE_OPTIONS`, we support arguments with values without the `=`
// sign. We need to parse them manually. // sign. We need to parse them manually.
let found = null let orphan = null
for (let i = 0; i < tokens.length; i++) { for (let i = 0; i < tokens.length; i++) {
const token = tokens[i] const token = tokens[i]
@ -24,37 +24,105 @@ const parseNodeArgs = (args: string[]) => {
break break
} }
// If we haven't found a possibly orphaned option, we need to look for one. // When we encounter an option, if it's value is undefined, we should check
if (!found) { // to see if the following tokens are positional parameters. If they are,
if (token.kind === 'option' && typeof token.value === 'undefined') { // then the option is orphaned, and we can assign it.
found = token if (token.kind === 'option') {
} orphan = typeof token.value === 'undefined' ? token : null
continue continue
} }
// If the next token isn't a positional value, then it's truly orphaned. // If the token isn't a positional one, then we can't assign it to the found
if (token.kind !== 'positional' || !token.value) { // orphaned option.
found = null if (token.kind !== 'positional') {
orphan = null
continue continue
} }
// We found an orphaned option. Let's add it to the values. // If we don't have an orphan, then we can skip this token.
values[found.name] = token.value if (!orphan) {
found = null continue
}
// If the token is a positional one, and it has a value, so add it to the
// values object. If it already exists, append it with a space.
if (orphan.name in values && typeof values[orphan.name] === 'string') {
values[orphan.name] += ` ${token.value}`
} else {
values[orphan.name] = token.value
}
} }
return values return values
} }
/**
* Tokenizes the arguments string into an array of strings, supporting quoted
* values and escaped characters.
* Converted from: https://github.com/nodejs/node/blob/c29d53c5cfc63c5a876084e788d70c9e87bed880/src/node_options.cc#L1401
*
* @param input The arguments string to be tokenized.
* @returns An array of strings with the tokenized arguments.
*/
export const tokenizeArgs = (input: string): string[] => {
let args: string[] = []
let isInString = false
let willStartNewArg = true
for (let i = 0; i < input.length; i++) {
let char = input[i]
// Skip any escaped characters in strings.
if (char === '\\' && isInString) {
// Ensure we don't have an escape character at the end.
if (input.length === i + 1) {
throw new Error('Invalid escape character at the end.')
}
// Skip the next character.
char = input[++i]
}
// If we find a space outside of a string, we should start a new argument.
else if (char === ' ' && !isInString) {
willStartNewArg = true
continue
}
// If we find a quote, we should toggle the string flag.
else if (char === '"') {
isInString = !isInString
continue
}
// If we're starting a new argument, we should add it to the array.
if (willStartNewArg) {
args.push(char)
willStartNewArg = false
}
// Otherwise, add it to the last argument.
else {
args[args.length - 1] += char
}
}
if (isInString) {
throw new Error('Unterminated string')
}
return args
}
/** /**
* Get the node options from the environment variable `NODE_OPTIONS` and returns * Get the node options from the environment variable `NODE_OPTIONS` and returns
* them as an array of strings. * them as an array of strings.
* *
* @returns An array of strings with the node options. * @returns An array of strings with the node options.
*/ */
const getNodeOptionsArgs = () => const getNodeOptionsArgs = () => {
process.env.NODE_OPTIONS?.split(' ').map((arg) => arg.trim()) ?? [] if (!process.env.NODE_OPTIONS) return []
return tokenizeArgs(process.env.NODE_OPTIONS)
}
/** /**
* The debug address is in the form of `[host:]port`. The host is optional. * The debug address is in the form of `[host:]port`. The host is optional.
@ -129,7 +197,13 @@ export function formatNodeOptions(
} }
if (value) { if (value) {
return `--${key}=${value}` return `--${key}=${
// Values with spaces need to be quoted. We use JSON.stringify to
// also escape any nested quotes.
value.includes(' ') && !value.startsWith('"')
? JSON.stringify(value)
: value
}`
} }
return null return null

View file

@ -0,0 +1 @@
console.log('FILE_WITH_SPACES_TO_REQUIRE_WITH_NODE_REQUIRE_OPTION')

View file

@ -0,0 +1 @@
console.log('FILE_WITH_SPACES_TO_REQUIRE_WITH_NODE_REQUIRE_OPTION')

View file

@ -559,31 +559,65 @@ describe('CLI Usage', () => {
} }
}) })
test("NODE_OPTIONS='--inspect=host:port'", async () => { test("NODE_OPTIONS='--require=file with spaces to-require-with-node-require-option.js'", async () => {
const port = await findPort() const port = await findPort()
const inspectPort = await findPort()
let output = '' let output = ''
let errOutput = '' let errOutput = ''
const app = await runNextCommandDev( const app = await runNextCommandDev(
[dirBasic, '--port', port], [dirBasic, '--port', port],
undefined, undefined,
{ {
cwd: dirBasic,
onStdout(msg) { onStdout(msg) {
output += stripAnsi(msg) output += stripAnsi(msg)
}, },
onStderr(msg) { onStderr(msg) {
errOutput += stripAnsi(msg) errOutput += stripAnsi(msg)
}, },
env: { NODE_OPTIONS: `--inspect=0.0.0.0:${inspectPort}` }, env: {
NODE_OPTIONS:
'--require "./file with spaces to-require-with-node-require-option.js"',
},
} }
) )
try { try {
await check(() => output, new RegExp(`http://localhost:${port}`)) await check(() => output, new RegExp(`http://localhost:${port}`))
await check(() => errOutput, /Debugger listening on/)
expect(errOutput).not.toContain('address already in use')
expect(output).toContain( expect(output).toContain(
'the --inspect option was detected, the Next.js router server should be inspected at' 'FILE_WITH_SPACES_TO_REQUIRE_WITH_NODE_REQUIRE_OPTION'
) )
expect(errOutput).toBe('')
} finally {
await killApp(app)
}
})
// Checks to make sure that files that look like arguments are not incorrectly parsed out. In this case the file name has `--require` in it.
test("NODE_OPTIONS='--require=file with spaces to --require.js'", async () => {
const port = await findPort()
let output = ''
let errOutput = ''
const app = await runNextCommandDev(
[dirBasic, '--port', port],
undefined,
{
cwd: dirBasic,
onStdout(msg) {
output += stripAnsi(msg)
},
onStderr(msg) {
errOutput += stripAnsi(msg)
},
env: {
NODE_OPTIONS: '--require "./file with spaces to --require.js"',
},
}
)
try {
await check(() => output, new RegExp(`http://localhost:${port}`))
expect(output).toContain(
'FILE_WITH_SPACES_TO_REQUIRE_WITH_NODE_REQUIRE_OPTION'
)
expect(errOutput).toBe('')
} finally { } finally {
await killApp(app) await killApp(app)
} }