Skip to content

Commit 4e2d407

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@3383374160cf4a5b7da54f12960f67b4e62e800b
1 parent 3a37e5c commit 4e2d407

2 files changed

Lines changed: 85 additions & 12 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
2+
import os from 'os'
3+
import path from 'path'
4+
5+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
6+
7+
import { openFileAtPath } from '../open-file'
8+
9+
import type { CliEnv } from '../../types/env'
10+
11+
// The editor command runs through `shell: true`; these use POSIX `sh`.
12+
describe.skipIf(process.platform === 'win32')('openFileAtPath placeholders', () => {
13+
let dir: string
14+
15+
beforeEach(() => {
16+
dir = mkdtempSync(path.join(os.tmpdir(), 'open-file-'))
17+
})
18+
19+
afterEach(() => {
20+
rmSync(dir, { recursive: true, force: true })
21+
})
22+
23+
// A repo-controlled agent file name: shell metacharacters, a quote, and
24+
// `$&` / `$'` (special in String.replace replacement strings).
25+
const hostile = () =>
26+
path.join(dir, `a b';touch ${path.join(dir, 'pwned')};echo '$&$'c`)
27+
28+
const run = async (editor: string, filePath: string) => {
29+
const out = path.join(dir, 'out')
30+
const env = {
31+
CODEBUFF_EDITOR: editor.replace('OUT', out),
32+
} as unknown as CliEnv
33+
const ok = await openFileAtPath(filePath, env)
34+
return { ok, out }
35+
}
36+
37+
// Quoted forms too: a user's `code -g "%f"` must not hand the editor the
38+
// already-quoted path wrapped in a second pair of quotes.
39+
test.each(['%f', '{file}', '"%f"', "'%f'", '"{file}"'])(
40+
'%s passes the path as one literal argument and runs nothing else',
41+
async (placeholder) => {
42+
const filePath = hostile()
43+
const { ok, out } = await run(
44+
`sh -c 'printf %s "$1" > OUT' _ ${placeholder}`,
45+
filePath,
46+
)
47+
48+
expect(ok).toBe(true)
49+
expect(readFileSync(out, 'utf8')).toBe(filePath)
50+
expect(existsSync(path.join(dir, 'pwned'))).toBe(false)
51+
},
52+
)
53+
54+
test('a command separator in the file name does not run a second command', async () => {
55+
const filePath = path.join(dir, `a;touch ${path.join(dir, 'pwned')}`)
56+
const { ok } = await run(`true %f`, filePath)
57+
58+
expect(ok).toBe(true)
59+
expect(existsSync(path.join(dir, 'pwned'))).toBe(false)
60+
})
61+
62+
test('an editor without a placeholder still gets the quoted path appended', async () => {
63+
const filePath = hostile()
64+
const { ok, out } = await run(
65+
`sh -c 'printf %s "$1" > OUT' _`,
66+
filePath,
67+
)
68+
69+
expect(ok).toBe(true)
70+
expect(readFileSync(out, 'utf8')).toBe(filePath)
71+
expect(existsSync(path.join(dir, 'pwned'))).toBe(false)
72+
})
73+
})

‎cli/src/utils/open-file.ts‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,23 @@ const escapeForShell = (value: string): string =>
1515
const escapeForCmd = (value: string): string =>
1616
`"${value.replace(/"/g, '""')}"`
1717

18-
const replaceFilePlaceholder = (command: string, filePath: string): string => {
19-
if (command.includes('%f')) {
20-
return command.replace(/%f/g, filePath)
21-
}
22-
if (command.includes('{file}')) {
23-
return command.replace(/{file}/g, filePath)
24-
}
25-
return command
26-
}
18+
// `%f` or `{file}`, with any matching quotes a user put around it (`"%f"`):
19+
// the substituted path is already quoted, and quoting it twice would hand the
20+
// editor a path with literal quote marks in it.
21+
const FILE_PLACEHOLDER = /(["']?)(%f|\{file\})\1/g
22+
23+
// filePath must already be shell-quoted: the command runs with `shell: true`, and
24+
// the path can come from a repo (agent file names). The function replacer keeps
25+
// `$&` / `$'` in a file name from being read as replacement patterns.
26+
const replaceFilePlaceholder = (command: string, filePath: string): string =>
27+
command.replace(FILE_PLACEHOLDER, () => filePath)
2728

2829
const buildEditorCommands = (
2930
filePath: string,
3031
env: CliEnv = getCliEnv(),
3132
): string[] => {
3233
const commands: string[] = []
3334
const shellPath = isWindows ? escapeForCmd(filePath) : escapeForShell(filePath)
34-
const rawPath = filePath
3535

3636
// Check custom editor env vars
3737
const editorValues = [
@@ -43,11 +43,11 @@ const buildEditorCommands = (
4343

4444
for (const value of editorValues) {
4545
if (!value) continue
46-
const withFile = replaceFilePlaceholder(value, rawPath)
46+
const withFile = replaceFilePlaceholder(value, shellPath)
4747
if (withFile !== value) {
4848
commands.push(withFile)
4949
} else {
50-
commands.push(`${value} ${isWindows ? shellPath : shellPath}`)
50+
commands.push(`${value} ${shellPath}`)
5151
}
5252
}
5353

0 commit comments

Comments
 (0)