Skip to content

Commit a19e52a

Browse files
committed
fix: shellescape prettier command paths
1 parent 739152f commit a19e52a

6 files changed

Lines changed: 117 additions & 24 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ plugin versions:
131131
project-local Prettier outside this checkout.
132132
- `yarn lint` now uses pinned local Python requirements and a checkout-local
133133
virtualenv wrapper for `vim-vint`, and CI runs it as a blocking job.
134+
- Hardened the current POSIX shell-string command path by returning raw
135+
executable paths from the resolver and shellescaping executable and
136+
`--stdin-filepath` arguments at shell call sites. Windows and argv-list job
137+
APIs remain open.
134138

135139
## Review Findings After b538e29
136140

@@ -200,7 +204,9 @@ plugin versions:
200204
- [x] Fix resolver to search for Prettier from the buffer's file tree, not only
201205
`getcwd()`.
202206
- [x] Avoid mutating buffer filetype defaults when merging overrides.
203-
- [ ] Make command construction shell-safe for spaces, quotes, and Windows.
207+
- [x] Harden POSIX shell-string command construction for executable paths and
208+
`--stdin-filepath` paths containing spaces and quotes.
209+
- [ ] Finish command construction hardening for Windows.
204210
- [ ] Prefer argv-list job/system APIs where Vim/Neovim support allows.
205211
- [ ] Expand config-file discovery for modern Prettier config names.
206212

autoload/prettier.vim

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ function! prettier#PrettierCli(user_input) abort
2727
let l:execCmd = prettier#resolver#executable#getPath()
2828

2929
if l:execCmd != -1
30-
let l:out = system(l:execCmd. ' ' . a:user_input)
30+
let l:out = system(shellescape(l:execCmd) . ' ' . a:user_input)
3131
echom l:out
3232
else
3333
call prettier#logging#error#log('EXECUTABLE_NOT_FOUND_ERROR')
@@ -65,7 +65,7 @@ function! prettier#Prettier(...) abort
6565
" TODO
6666
" => we should make sure we can resolve --range-start and --range-end when required
6767
" => when the above is required we should also update l:startSelection to '1' and l:endSelection to line('$')
68-
let l:cmd = l:execCmd . prettier#resolver#config#resolve(
68+
let l:cmd = shellescape(l:execCmd) . prettier#resolver#config#resolve(
6969
\ prettier#resolver#preset#resolve(l:config),
7070
\ l:partialFormatEnabled,
7171
\ l:startSelection,

autoload/prettier/resolver/config.vim

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ endfunction
147147
" file.
148148
function! s:Flag_stdin_filepath(...) abort
149149
let l:current_file = simplify(expand('%:p'))
150-
return '--stdin-filepath="' . l:current_file . '"'
150+
return '--stdin-filepath=' . shellescape(l:current_file)
151151
endfunction
152152

153153
" Returns '--loglevel error' or '--log-level error'.
@@ -304,7 +304,7 @@ function! s:Get_prettier_cli_version() abort
304304
return ''
305305
endif
306306

307-
let l:output = system(l:execCmd . ' --version')
307+
let l:output = system(shellescape(l:execCmd) . ' --version')
308308
" The shell sends the string with whitespaces at both ends.
309309
let l:prettier_cli_version = s:Trim_internal_unprintable(trim(l:output))
310310
return l:prettier_cli_version

autoload/prettier/resolver/executable.vim

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ let s:ROOT_DIR = fnamemodify(resolve(expand('<sfile>:p')), ':h')
22
let s:PLUGIN_ROOT_DIR = fnamemodify(s:ROOT_DIR, ':h:h:h')
33

44
function! s:NormalizePath(path) abort
5-
let l:path = substitute(a:path, '\\ ', ' ', 'g')
6-
return substitute(resolve(fnamemodify(l:path, ':p')), '\\', '/', 'g')
5+
let l:path = resolve(fnamemodify(a:path, ':p'))
6+
return has('win32') || has('win64') ? substitute(l:path, '\\', '/', 'g') : l:path
77
endfunction
88

99
function! prettier#resolver#executable#isUnderPluginRoot(path) abort
@@ -39,24 +39,24 @@ function! prettier#resolver#executable#getPath() abort
3939
if isdirectory(l:bufferDir)
4040
let l:bufferLocalExec = s:ResolveExecutable(l:bufferDir)
4141
if executable(l:bufferLocalExec)
42-
return fnameescape(l:bufferLocalExec)
42+
return l:bufferLocalExec
4343
endif
4444
endif
4545
endif
4646

4747
let l:localExec = s:ResolveExecutable(getcwd())
4848
if executable(l:localExec)
49-
return fnameescape(l:localExec)
49+
return l:localExec
5050
endif
5151

5252
let l:globalExec = s:ResolveExecutable()
5353
if executable(l:globalExec)
54-
return fnameescape(l:globalExec)
54+
return l:globalExec
5555
endif
5656

5757
let l:pluginExec = s:ResolveExecutable(s:ROOT_DIR)
5858
if executable(l:pluginExec)
59-
return fnameescape(l:pluginExec)
59+
return l:pluginExec
6060
endif
6161

6262
return -1

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
},
1111
"scripts": {
1212
"test": "node scripts/vim-version.js && LOG_LEVEL=error PRETTIER_FORMATTING_FIXTURE_LANE=all jest",
13-
"test:smoke": "node scripts/vim-version.js && LOG_LEVEL=error PRETTIER_FORMATTING_FIXTURE_LANE=all jest tests/formatting.test.js --runInBand --testNamePattern=\"Prettier config\"",
13+
"test:smoke": "node scripts/vim-version.js && LOG_LEVEL=error PRETTIER_FORMATTING_FIXTURE_LANE=all jest tests/formatting.test.js --runInBand --testNamePattern=\"Prettier config|Prettier command shell\"",
1414
"test:formatting:passing": "node scripts/vim-version.js && LOG_LEVEL=error PRETTIER_FORMATTING_FIXTURE_LANE=known-passing jest tests/formatting.test.js --runInBand",
1515
"test:formatting:quarantined": "node scripts/vim-version.js && LOG_LEVEL=error PRETTIER_FORMATTING_FIXTURE_LANE=quarantined jest tests/formatting.test.js --runInBand",
1616
"lint": "sh scripts/vint.sh --version && sh scripts/vint.sh ."

tests/formatting.test.js

Lines changed: 99 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ const getBufferContents = async remote =>
4848

4949
const vimString = value => `'${value.replace(/'/g, "''")}'`;
5050

51+
const vimStringExpr = value =>
52+
value
53+
.split('"')
54+
.map(vimString)
55+
.join(' . nr2char(34) . ');
56+
5157
const removeDirectorySync = dir => {
5258
if (!fs.existsSync(dir)) {
5359
return;
@@ -72,17 +78,41 @@ const cleanupTempProjects = () => {
7278
}
7379
};
7480

75-
const writeFakePrettierExecutable = prettierPath => {
76-
fs.writeFileSync(
77-
prettierPath,
78-
[
79-
'#!/bin/sh',
80-
'if [ "$1" = "--version" ]; then',
81-
" printf '3.0.3\\n'",
82-
'fi',
83-
'exit 0',
84-
].join('\n') + '\n'
85-
);
81+
const writeFakePrettierExecutable = (prettierPath, options = {}) => {
82+
const script = [
83+
'#!/bin/sh',
84+
'if [ "$1" = "--version" ]; then',
85+
" printf '3.0.3\\n'",
86+
' exit 0',
87+
'fi',
88+
];
89+
90+
if (options.expectedStdinFilepath) {
91+
script.push(
92+
`expected_stdin_filepath=${shellQuote(options.expectedStdinFilepath)}`,
93+
'found_stdin_filepath=0',
94+
'for arg in "$@"; do',
95+
' if [ "$arg" = "--stdin-filepath=$expected_stdin_filepath" ]; then',
96+
' found_stdin_filepath=1',
97+
' fi',
98+
'done',
99+
'if [ "$found_stdin_filepath" != "1" ]; then',
100+
" printf '%s\\n' 'missing expected stdin filepath' >&2",
101+
' exit 2',
102+
'fi'
103+
);
104+
}
105+
106+
if (Object.prototype.hasOwnProperty.call(options, 'formattedOutput')) {
107+
script.push(
108+
'cat >/dev/null',
109+
`printf '%s\\n' ${shellQuote(options.formattedOutput)}`
110+
);
111+
}
112+
113+
script.push('exit 0');
114+
115+
fs.writeFileSync(prettierPath, script.join('\n') + '\n');
86116
fs.chmodSync(prettierPath, 0o755);
87117
};
88118

@@ -105,8 +135,32 @@ const createProjectLocalPrettierFixture = extension => {
105135
return { root, prettierPath, sourcePath };
106136
};
107137

138+
const createShellSafetyPrettierFixture = () => {
139+
const root = fs.mkdtempSync(
140+
path.join(fs.realpathSync(os.tmpdir()), 'vim-prettier shell "quote" ')
141+
);
142+
const binDir = path.join(root, 'node_modules', '.bin');
143+
const sourceDir = path.join(root, 'src with spaces');
144+
const prettierPath = path.join(binDir, 'prettier');
145+
const sourcePath = path.join(sourceDir, 'index "quote".js');
146+
147+
tempProjectRoots.push(root);
148+
fs.mkdirSync(binDir, { recursive: true });
149+
fs.mkdirSync(sourceDir, { recursive: true });
150+
writeFakePrettierExecutable(prettierPath, {
151+
expectedStdinFilepath: sourcePath,
152+
formattedOutput: 'const formatted = true;',
153+
});
154+
fs.writeFileSync(sourcePath, 'const formatted=false\n');
155+
156+
return { root, prettierPath, sourcePath };
157+
};
158+
108159
const setVimCwd = dir =>
109-
remote.execute(`execute 'cd' fnameescape(${vimString(dir)})`);
160+
remote.execute(`execute 'cd' fnameescape(${vimStringExpr(dir)})`);
161+
162+
const editFile = file =>
163+
remote.execute(`execute 'edit' fnameescape(${vimStringExpr(file)})`);
110164

111165
const resolveConfigFlags = config =>
112166
remote.eval(`prettier#resolver#config#resolve(${config}, 0, 1, 1)`);
@@ -430,6 +484,39 @@ if (FORMAT_FIXTURE_LANE === 'all') {
430484
expectNoPluginFlags(flags);
431485
});
432486
});
487+
488+
describe('Prettier command shell safety', () => {
489+
test('shellescapes stdin filepath for paths containing spaces and quotes', async () => {
490+
const project = createShellSafetyPrettierFixture();
491+
492+
await editFile(project.sourcePath);
493+
494+
const flags = await resolveConfigFlags('{}');
495+
const expectedPath = await remote.eval(
496+
"shellescape(simplify(expand('%:p')))"
497+
);
498+
499+
expect(flags).toContain(`--stdin-filepath=${expectedPath}`);
500+
expect(flags).not.toContain(`--stdin-filepath="${project.sourcePath}"`);
501+
});
502+
503+
test(':Prettier runs a project-local executable from a shell-escaped path', async () => {
504+
const project = createShellSafetyPrettierFixture();
505+
506+
await setVimCwd(__dirname);
507+
await editFile(project.sourcePath);
508+
509+
const resolvedPath = await remote.eval(
510+
'prettier#resolver#executable#getPath()'
511+
);
512+
513+
expect(resolvedPath).toBe(project.prettierPath);
514+
515+
await remote.execute('Prettier');
516+
517+
expect(await getBufferContents(remote)).toBe('const formatted = true;');
518+
});
519+
});
433520
}
434521

435522
// run formatting tests in all fixtures

0 commit comments

Comments
 (0)