Skip to content

Commit ae1fe56

Browse files
timneutkenswyattjohEthan-Arrowood
authored
Ensure escaped string are parsed in NODE_OPTIONS (vercel#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: vercel#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>
1 parent af304c5 commit ae1fe56

File tree

5 files changed

+195
-21
lines changed

5 files changed

+195
-21
lines changed

packages/next/src/server/lib/utils.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {
22
getFormattedNodeOptionsWithoutInspect,
33
getParsedDebugAddress,
4+
formatNodeOptions,
5+
tokenizeArgs,
46
} from './utils'
57

68
const originalNodeOptions = process.env.NODE_OPTIONS
@@ -9,6 +11,48 @@ afterAll(() => {
911
process.env.NODE_OPTIONS = originalNodeOptions
1012
})
1113

14+
describe('tokenizeArgs', () => {
15+
it('splits arguments by spaces', () => {
16+
const result = tokenizeArgs('--spaces "thing with spaces" --normal 1234')
17+
18+
expect(result).toEqual([
19+
'--spaces',
20+
'thing with spaces',
21+
'--normal',
22+
'1234',
23+
])
24+
})
25+
26+
it('supports quoted values', () => {
27+
const result = tokenizeArgs(
28+
'--spaces "thing with spaces" --spacesAndQuotes "thing with \\"spaces\\"" --normal 1234'
29+
)
30+
31+
expect(result).toEqual([
32+
'--spaces',
33+
'thing with spaces',
34+
'--spacesAndQuotes',
35+
'thing with "spaces"',
36+
'--normal',
37+
'1234',
38+
])
39+
})
40+
})
41+
42+
describe('formatNodeOptions', () => {
43+
it('wraps values with spaces in quotes', () => {
44+
const result = formatNodeOptions({
45+
spaces: 'thing with spaces',
46+
spacesAndQuotes: 'thing with "spaces"',
47+
normal: '1234',
48+
})
49+
50+
expect(result).toBe(
51+
'--spaces="thing with spaces" --spacesAndQuotes="thing with \\"spaces\\"" --normal=1234'
52+
)
53+
})
54+
})
55+
1256
describe('getParsedDebugAddress', () => {
1357
it('supports the flag with an equal sign', () => {
1458
process.env.NODE_OPTIONS = '--inspect=1234'
@@ -38,6 +82,26 @@ describe('getFormattedNodeOptionsWithoutInspect', () => {
3882
expect(result).toBe('--other')
3983
})
4084

85+
it('handles options with spaces', () => {
86+
process.env.NODE_OPTIONS =
87+
'--other --inspect --additional --spaces "/some/path with spaces"'
88+
const result = getFormattedNodeOptionsWithoutInspect()
89+
90+
expect(result).toBe(
91+
'--other --additional --spaces="/some/path with spaces"'
92+
)
93+
})
94+
95+
it('handles options with quotes', () => {
96+
process.env.NODE_OPTIONS =
97+
'--require "./file with spaces to-require-with-node-require-option.js"'
98+
const result = getFormattedNodeOptionsWithoutInspect()
99+
100+
expect(result).toBe(
101+
'--require="./file with spaces to-require-with-node-require-option.js"'
102+
)
103+
})
104+
41105
it('removes --inspect option with parameters', () => {
42106
process.env.NODE_OPTIONS = '--other --inspect=0.0.0.0:1234 --additional'
43107
const result = getFormattedNodeOptionsWithoutInspect()

packages/next/src/server/lib/utils.ts

Lines changed: 89 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,45 +16,113 @@ const parseNodeArgs = (args: string[]) => {
1616

1717
// For the `NODE_OPTIONS`, we support arguments with values without the `=`
1818
// sign. We need to parse them manually.
19-
let found = null
19+
let orphan = null
2020
for (let i = 0; i < tokens.length; i++) {
2121
const token = tokens[i]
2222

2323
if (token.kind === 'option-terminator') {
2424
break
2525
}
2626

27-
// If we haven't found a possibly orphaned option, we need to look for one.
28-
if (!found) {
29-
if (token.kind === 'option' && typeof token.value === 'undefined') {
30-
found = token
31-
}
27+
// When we encounter an option, if it's value is undefined, we should check
28+
// to see if the following tokens are positional parameters. If they are,
29+
// then the option is orphaned, and we can assign it.
30+
if (token.kind === 'option') {
31+
orphan = typeof token.value === 'undefined' ? token : null
32+
continue
33+
}
3234

35+
// If the token isn't a positional one, then we can't assign it to the found
36+
// orphaned option.
37+
if (token.kind !== 'positional') {
38+
orphan = null
3339
continue
3440
}
3541

36-
// If the next token isn't a positional value, then it's truly orphaned.
37-
if (token.kind !== 'positional' || !token.value) {
38-
found = null
42+
// If we don't have an orphan, then we can skip this token.
43+
if (!orphan) {
3944
continue
4045
}
4146

42-
// We found an orphaned option. Let's add it to the values.
43-
values[found.name] = token.value
44-
found = null
47+
// If the token is a positional one, and it has a value, so add it to the
48+
// values object. If it already exists, append it with a space.
49+
if (orphan.name in values && typeof values[orphan.name] === 'string') {
50+
values[orphan.name] += ` ${token.value}`
51+
} else {
52+
values[orphan.name] = token.value
53+
}
4554
}
4655

4756
return values
4857
}
4958

59+
/**
60+
* Tokenizes the arguments string into an array of strings, supporting quoted
61+
* values and escaped characters.
62+
* Converted from: https://github.com/nodejs/node/blob/c29d53c5cfc63c5a876084e788d70c9e87bed880/src/node_options.cc#L1401
63+
*
64+
* @param input The arguments string to be tokenized.
65+
* @returns An array of strings with the tokenized arguments.
66+
*/
67+
export const tokenizeArgs = (input: string): string[] => {
68+
let args: string[] = []
69+
let isInString = false
70+
let willStartNewArg = true
71+
72+
for (let i = 0; i < input.length; i++) {
73+
let char = input[i]
74+
75+
// Skip any escaped characters in strings.
76+
if (char === '\\' && isInString) {
77+
// Ensure we don't have an escape character at the end.
78+
if (input.length === i + 1) {
79+
throw new Error('Invalid escape character at the end.')
80+
}
81+
82+
// Skip the next character.
83+
char = input[++i]
84+
}
85+
// If we find a space outside of a string, we should start a new argument.
86+
else if (char === ' ' && !isInString) {
87+
willStartNewArg = true
88+
continue
89+
}
90+
91+
// If we find a quote, we should toggle the string flag.
92+
else if (char === '"') {
93+
isInString = !isInString
94+
continue
95+
}
96+
97+
// If we're starting a new argument, we should add it to the array.
98+
if (willStartNewArg) {
99+
args.push(char)
100+
willStartNewArg = false
101+
}
102+
// Otherwise, add it to the last argument.
103+
else {
104+
args[args.length - 1] += char
105+
}
106+
}
107+
108+
if (isInString) {
109+
throw new Error('Unterminated string')
110+
}
111+
112+
return args
113+
}
114+
50115
/**
51116
* Get the node options from the environment variable `NODE_OPTIONS` and returns
52117
* them as an array of strings.
53118
*
54119
* @returns An array of strings with the node options.
55120
*/
56-
const getNodeOptionsArgs = () =>
57-
process.env.NODE_OPTIONS?.split(' ').map((arg) => arg.trim()) ?? []
121+
const getNodeOptionsArgs = () => {
122+
if (!process.env.NODE_OPTIONS) return []
123+
124+
return tokenizeArgs(process.env.NODE_OPTIONS)
125+
}
58126

59127
/**
60128
* The debug address is in the form of `[host:]port`. The host is optional.
@@ -129,7 +197,13 @@ export function formatNodeOptions(
129197
}
130198

131199
if (value) {
132-
return `--${key}=${value}`
200+
return `--${key}=${
201+
// Values with spaces need to be quoted. We use JSON.stringify to
202+
// also escape any nested quotes.
203+
value.includes(' ') && !value.startsWith('"')
204+
? JSON.stringify(value)
205+
: value
206+
}`
133207
}
134208

135209
return null
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
console.log('FILE_WITH_SPACES_TO_REQUIRE_WITH_NODE_REQUIRE_OPTION')
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
console.log('FILE_WITH_SPACES_TO_REQUIRE_WITH_NODE_REQUIRE_OPTION')

test/integration/cli/test/index.test.js

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -559,31 +559,65 @@ describe('CLI Usage', () => {
559559
}
560560
})
561561

562-
test("NODE_OPTIONS='--inspect=host:port'", async () => {
562+
test("NODE_OPTIONS='--require=file with spaces to-require-with-node-require-option.js'", async () => {
563563
const port = await findPort()
564-
const inspectPort = await findPort()
565564
let output = ''
566565
let errOutput = ''
567566
const app = await runNextCommandDev(
568567
[dirBasic, '--port', port],
569568
undefined,
570569
{
570+
cwd: dirBasic,
571571
onStdout(msg) {
572572
output += stripAnsi(msg)
573573
},
574574
onStderr(msg) {
575575
errOutput += stripAnsi(msg)
576576
},
577-
env: { NODE_OPTIONS: `--inspect=0.0.0.0:${inspectPort}` },
577+
env: {
578+
NODE_OPTIONS:
579+
'--require "./file with spaces to-require-with-node-require-option.js"',
580+
},
578581
}
579582
)
580583
try {
581584
await check(() => output, new RegExp(`http://localhost:${port}`))
582-
await check(() => errOutput, /Debugger listening on/)
583-
expect(errOutput).not.toContain('address already in use')
584585
expect(output).toContain(
585-
'the --inspect option was detected, the Next.js router server should be inspected at'
586+
'FILE_WITH_SPACES_TO_REQUIRE_WITH_NODE_REQUIRE_OPTION'
587+
)
588+
expect(errOutput).toBe('')
589+
} finally {
590+
await killApp(app)
591+
}
592+
})
593+
594+
// 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.
595+
test("NODE_OPTIONS='--require=file with spaces to --require.js'", async () => {
596+
const port = await findPort()
597+
let output = ''
598+
let errOutput = ''
599+
const app = await runNextCommandDev(
600+
[dirBasic, '--port', port],
601+
undefined,
602+
{
603+
cwd: dirBasic,
604+
onStdout(msg) {
605+
output += stripAnsi(msg)
606+
},
607+
onStderr(msg) {
608+
errOutput += stripAnsi(msg)
609+
},
610+
env: {
611+
NODE_OPTIONS: '--require "./file with spaces to --require.js"',
612+
},
613+
}
614+
)
615+
try {
616+
await check(() => output, new RegExp(`http://localhost:${port}`))
617+
expect(output).toContain(
618+
'FILE_WITH_SPACES_TO_REQUIRE_WITH_NODE_REQUIRE_OPTION'
586619
)
620+
expect(errOutput).toBe('')
587621
} finally {
588622
await killApp(app)
589623
}

0 commit comments

Comments
 (0)