Compare commits

...
Author SHA1 Message Date
Kit Langton a6320dfc25 fix(core): close remaining shell scanner soundness gaps and false opacity 2026-10-04 22:20:48 -07:00
Kit Langton a61ed90513 test(core): verify oracle fixture execution only in strict mode 2026-10-04 21:16:43 -07:00
Kit Langton f8dd0ae352 test(core): drop empty known-gap scaffolding 2026-10-04 21:04:45 -07:00
Kit Langton e8cbdb8a7f fix(core): fail closed on zsh array adjacency and dash script aliases 2026-10-04 21:04:45 -07:00
Kit Langton d768766dc4 fix(core): fail closed on zsh flag groups and glob modifiers 2026-10-04 21:04:45 -07:00
Kit Langton fa69d1d6dc fix(core): check bash arithmetic contexts and bound values for literal expansions 2026-10-04 21:04:45 -07:00
Kit Langton 5bcebfa47d fix(core): reject quote-hidden parameter ends in bash arithmetic quotes 2026-10-04 21:04:45 -07:00
Kit Langton 005e195065 fix(core): fail closed on bash arithmetic that rereads as a subshell 2026-10-04 21:04:45 -07:00
Kit Langton 89b4f2cf9b fix(core): scan dash double parentheses under a posix dialect 2026-10-04 21:04:45 -07:00
Kit Langton 454d47497d fix(core): share pending bash heredocs across nested scans 2026-10-04 21:04:45 -07:00
Kit Langton 3993dce7c9 test(core): add failing fixtures for reviewer-confirmed scanner misses 2026-10-04 21:04:44 -07:00
Kit Langton e7ae5a7725 fix(core): limit bash subscript evaluation checks to shell sinks 2026-10-04 21:04:44 -07:00
Kit Langton 8a6f4a11ab docs(core): note the runtime limit of bash subscript checks 2026-10-04 21:04:44 -07:00
Kit Langton 81926b2e1b refactor(core): skip the bash unit scanner for ordinary characters 2026-10-04 21:04:44 -07:00
Kit Langton 66329a0e4f test(core): generate nested real-shell oracle fixtures from one list 2026-10-04 21:04:44 -07:00
Kit Langton 1cdc33d33b fix(core): mark every bash expansion in decoded subscript text 2026-10-04 21:04:44 -07:00
Kit Langton 2470f6027a refactor(core): read bash function heads and continuations declaratively 2026-10-04 21:04:44 -07:00
Kit Langton 527cfd6037 fix(core): fail closed on bash reserved words after redirects 2026-10-04 21:04:44 -07:00
Kit Langton bd7ab857a0 fix(core): scan posix for loops without an in list 2026-10-04 21:04:44 -07:00
Kit Langton 2cc542c9d0 fix(core): fail closed on zsh glob qualifier positions 2026-10-04 21:04:44 -07:00
Kit Langton f37512680a fix(core): fail closed on bash subscript and parameter evaluation 2026-10-04 21:04:44 -07:00
Kit Langton 6dd193c70d refactor(core): split bash and powershell directory word resolution 2026-10-04 21:04:44 -07:00
Kit Langton 46d263f2eb fix(core): bound bash heredoc continuation scanning 2026-10-04 21:04:44 -07:00
Kit Langton 1764faded7 fix(core): fail closed on words after a bash ampersand redirect 2026-10-04 21:04:43 -07:00
Kit Langton 7fc28a7361 fix(core): match bash operators and assignment subscripts structurally 2026-10-04 21:04:43 -07:00
Kit Langton 32f62edf9e fix(core): end case pattern words at bracket metacharacters 2026-10-04 21:04:43 -07:00
Kit Langton b9c934a93d fix(core): scan bash quoting and expansions with one primitive 2026-10-04 21:04:43 -07:00
Kit Langton 9926646c77 refactor(core): track bash list state in one place 2026-10-04 21:04:43 -07:00
Kit Langton 0974946864 refactor(core): remove dead bash scanner state and guards 2026-10-04 21:04:43 -07:00
Kit Langton d0e57b4697 test(core): add failing real-shell fixtures for scanner soundness gaps 2026-10-04 21:04:43 -07:00
Kit Langton f3bcd5372a test(core): skip real-shell oracle on Windows and format scanner 2026-10-04 21:04:43 -07:00
Kit Langton 0abbbe007f fix(core): tighten case and for header validation and expand oracle 2026-10-04 21:04:43 -07:00
Kit Langton 6d9efafbfe fix(core): unify bash command scanner with recursive-descent parsing
Replace bashDelimited and bashExpansion with recursive-descent parsing in
scanBash so nested subshells, command substitutions, process substitutions,
brace groups, and nofork substitutions use the same tokenizer and command
parser rather than a separate delimiter pre-scan.

Update redirect.test.ts for 'pwd && cd >out /outside', where a real shell
executes cd /outside after the redirect while legacy tree-sitter drops the
trailing directory operand.
2026-10-04 21:04:43 -07:00
Kit Langton f6d9a842b6 test(core): add real-shell soundness oracle for bash scanner 2026-10-04 21:04:42 -07:00
Kit Langton 25c54cbdf7 fix(client): wait for service shutdown before restart (#50042) 2026-10-04 20:59:19 -07:00
Luke Parker ae57dd0cf5 fix(app): close the running menu before opening a subagent (#53273) 2026-10-05 13:58:19 +10:00
14 changed files with 2264 additions and 808 deletions

No files matched your search

@@ -5,6 +5,7 @@ import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useServer } from "@/runtime/server/current"
@@ -35,6 +36,7 @@ export function SessionRunningMenu(props: {
const openRoute = useOpenSessionRoute()
const server = useServer()
const sdk = useServerSDK()
const [menu, setMenu] = createStore({ open: false })
const sessionAgent = (id: string | undefined) => (id ? server.ctx.data.session.get(id)?.agent : undefined)
// Foreground shells stay out: the timeline already shows them at the bottom.
@@ -105,7 +107,7 @@ export function SessionRunningMenu(props: {
return (
<Show when={items().length > 0}>
<Menu gutter={6} placement="bottom-start">
<Menu gutter={6} placement="bottom-start" open={menu.open} onOpenChange={(open) => setMenu("open", open)}>
<Menu.Trigger
as="button"
type="button"
@@ -123,7 +125,12 @@ export function SessionRunningMenu(props: {
class="group/running-item"
classList={{ "!bg-v2-overlay-simple-overlay-hover": viewing(item) }}
aria-current={viewing(item) ? "page" : undefined}
onSelect={() => open(item)}
onSelect={() => {
// Close before navigating: the router's transition would hold the close until this
// timeline detaches, and the menu would flash at the viewport origin.
setMenu("open", false)
open(item)
}}
onKeyDown={(event) => {
if ((event.key !== "Delete" && event.key !== "Backspace") || !stoppable(item)) return
+4 -6
View File
@@ -157,10 +157,8 @@ export const stop = Effect.fn("service.stop")(function* (options: StopOptions =
options.pty === "handoff" && info !== undefined
? PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout)
: PtyHandoff.clear(options.file ?? fallback()),
).pipe(
Effect.catch((cause) => Effect.logWarning("Failed to prepare persistent terminals for replacement", cause)),
)
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
).pipe(Effect.catch((cause) => Effect.logWarning("Failed to prepare persistent terminals for replacement", cause)))
if (info !== undefined) yield* terminate(info, options, ensureTiming(options))
})
function fallback() {
@@ -298,9 +296,9 @@ const terminate = Effect.fnUntraced(function* (info: Info, options: { readonly f
if (current === undefined || !same(current, info)) return
yield* signal(info.pid, "SIGTERM")
const done = yield* stopped(info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
// The registration can disappear or change hands before this process exits. Only the PID we
// signalled can tell us whether it has stopped, so escalate based on that process.
if (Option.isNone(done)) {
const latest = yield* read(options.file)
if (latest === undefined || !same(latest, info)) return
yield* signal(info.pid, "SIGKILL")
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
}
+3 -3
View File
@@ -129,7 +129,7 @@ export async function stop(options: StopOptions = {}) {
? PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout)
: PtyHandoff.clear(options.file ?? fallback())
).catch((cause: unknown) => console.warn("Failed to prepare persistent terminals for replacement", cause))
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
if (info !== undefined) await terminate(info, options, ensureTiming(options))
}
function fallback() {
@@ -258,9 +258,9 @@ async function terminate(info: Info, options: { readonly file?: string }, timing
const current = await read(options.file)
if (current === undefined || !same(current, info)) return
signal(info.pid, "SIGTERM")
// The registration can disappear or change hands before this process exits. Only the PID we
// signalled can tell us whether it has stopped, so escalate based on that process.
if (!(await waitUntilStopped(info.pid, timing))) {
const latest = await read(options.file)
if (latest === undefined || !same(latest, info)) return
signal(info.pid, "SIGKILL")
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
}
@@ -25,6 +25,16 @@ export async function serviceFixture() {
processes.push(subprocess)
return subprocess
},
// The service's parent execs `sleep`, which never reaps it, so SIGKILL leaves a zombie that
// still answers `kill(pid, 0)`. Terminating the returned parent lets init reap the service.
spawnUnreaped(mode: string, ...args: string[]) {
const subprocess = Bun.spawn(["sh", "-c", '"$@" & exec sleep 60', "sh", ...command(mode, ...args)], {
stdout: "ignore",
stderr: "inherit",
})
processes.push(subprocess)
return subprocess
},
// Service.ensure detaches contenders; track the elected process before asserting.
track(pid: number) {
pids.add(pid)
@@ -54,3 +64,8 @@ export async function serviceFixture() {
},
}
}
export async function expectPortAvailable(url: string) {
const server = Bun.serve({ port: Number(new URL(url).port), fetch: () => new Response() })
await server.stop(true)
}
+11 -3
View File
@@ -1,4 +1,4 @@
import { appendFile, rename, writeFile } from "node:fs/promises"
import { appendFile, rename, rm, writeFile } from "node:fs/promises"
const [registration, mode, delay] = process.argv.slice(2)
if (registration === undefined || mode === undefined) throw new Error("Missing service fixture arguments")
@@ -91,6 +91,10 @@ const server = Bun.serve({
},
})
// Install handlers before publishing: a test may signal as soon as the registration appears.
process.on("SIGTERM", () => void shutdown("SIGTERM"))
process.on("SIGINT", () => void shutdown("SIGINT"))
await writeFile(
registration + ".tmp",
JSON.stringify({
@@ -106,8 +110,12 @@ await rename(registration + ".tmp", registration)
async function shutdown(signal?: NodeJS.Signals) {
if (signal !== undefined) await writeFile(registration + ".signal", signal)
// A lingering server unregisters on SIGTERM but keeps running, and holds its port, until killed.
if (mode === "lingering") {
await rm(registration, { force: true })
await writeFile(registration + ".unregistered", "")
await Bun.sleep(Number(delay))
}
server.stop(true)
process.exit()
}
process.on("SIGTERM", () => void shutdown("SIGTERM"))
process.on("SIGINT", () => void shutdown("SIGINT"))
+65 -1
View File
@@ -1,9 +1,10 @@
import { expect, test } from "bun:test"
import { Service, type EnsureReason } from "../src/promise/service"
import { serviceFixture } from "./fixture/service-fixture"
import { expectPortAvailable, serviceFixture } from "./fixture/service-fixture"
import { accelerate } from "./fixture/service-timing"
const ensure = accelerate(Service.ensure)
const stop = accelerate(Service.stop)
test("discovers a registered service", async () => {
await using fixture = await serviceFixture()
@@ -171,3 +172,66 @@ test("signals the registered service process", async () => {
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(await Bun.file(registration).exists()).toBe(false)
})
test("stop escalates when the registration disappears before the process exits", async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
const existing = fixture.spawn("lingering", "5000")
await fixture.waitForFile()
const original = await Bun.file(registration).json()
await stop({ file: registration })
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(() => process.kill(original.pid, 0)).toThrow()
await expectPortAvailable(original.url)
expect(await Bun.file(registration).exists()).toBe(false)
await existing.exited
}, 15_000)
test.skipIf(process.platform === "win32")(
"stop fails when the process survives SIGKILL",
async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
fixture.spawnUnreaped("lingering", "60000")
await fixture.waitForFile()
const original = await Bun.file(registration).json()
await expect(stop({ file: registration })).rejects.toThrow(`Server process ${original.pid} is still running`)
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
},
15_000,
)
test("stop waits for the original process while preserving a newly registered successor", async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
const existing = fixture.spawn("lingering", "15000")
await fixture.waitForFile()
const original = await Bun.file(registration).json()
// Default timing keeps the grace period open long enough for the successor to register first.
const stopping = Service.stop({ file: registration })
try {
await fixture.waitForFile(registration + ".unregistered")
const successor = fixture.spawn("graceful")
await fixture.waitForFile()
const replacement = await Bun.file(registration).json()
expect(existing.exitCode).toBe(null)
expect(replacement.pid).toBe(successor.pid)
await stopping
expect(() => process.kill(original.pid, 0)).toThrow()
await expectPortAvailable(original.url)
expect(await Bun.file(registration).json()).toEqual(replacement)
expect(await fetch(new URL("/api/info", replacement.url)).then((response) => response.json())).toMatchObject({
pid: successor.pid,
})
expect(successor.exitCode).toBe(null)
} finally {
existing.kill("SIGKILL")
await stopping
}
}, 20_000)
+63 -1
View File
@@ -3,10 +3,11 @@ import { expect, test } from "bun:test"
import { Effect, FileSystem } from "effect"
import { writeFile } from "node:fs/promises"
import { Service, type EnsureReason } from "../src/effect/service"
import { serviceFixture } from "./fixture/service-fixture"
import { expectPortAvailable, serviceFixture } from "./fixture/service-fixture"
import { accelerate } from "./fixture/service-timing"
const ensure = accelerate(Service.ensure)
const stop = accelerate(Service.stop)
test("a concurrent same-version start cannot invalidate a resolved endpoint", async () => {
await using fixture = await serviceFixture()
@@ -161,6 +162,67 @@ test("signals an unresponsive registered service process", async () => {
expect(await Bun.file(registration).exists()).toBe(false)
})
test("stop escalates when the registration disappears before the process exits", async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
const existing = fixture.spawn("lingering", "5000")
await fixture.waitForFile()
const original = await Bun.file(registration).json()
await run(stop({ file: registration }))
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(() => process.kill(original.pid, 0)).toThrow()
await expectPortAvailable(original.url)
expect(await Bun.file(registration).exists()).toBe(false)
await existing.exited
}, 15_000)
test.skipIf(process.platform === "win32")(
"stop fails when the process survives SIGKILL",
async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
fixture.spawnUnreaped("lingering", "60000")
await fixture.waitForFile()
const original = await Bun.file(registration).json()
await expect(run(stop({ file: registration }))).rejects.toThrow(`Server process ${original.pid} is still running`)
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
},
15_000,
)
test("stop waits for the original process while preserving a newly registered successor", async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
const existing = fixture.spawn("lingering", "15000")
await fixture.waitForFile()
const original = await Bun.file(registration).json()
// Default timing keeps the grace period open long enough for the successor to register first.
const stopping = run(Service.stop({ file: registration }))
try {
await fixture.waitForFile(registration + ".unregistered")
const successor = fixture.spawn("graceful")
await fixture.waitForFile()
const replacement = await Bun.file(registration).json()
expect(existing.exitCode).toBe(null)
expect(replacement.pid).toBe(successor.pid)
await stopping
expect(() => process.kill(original.pid, 0)).toThrow()
await expectPortAvailable(original.url)
expect(await Bun.file(registration).json()).toEqual(replacement)
expect(await status(replacement.url)).toMatchObject({ pid: successor.pid })
expect(successor.exitCode).toBe(null)
} finally {
existing.kill("SIGKILL")
await stopping
}
}, 20_000)
test("signals an incompatible service before starting its replacement", async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
+19 -10
View File
@@ -210,7 +210,10 @@ export const scanPortable = Effect.fnUntraced(function* (command: string, shell:
catch: (cause) => new Error(`Portable shell scanner failed to load: ${cause}`, { cause }),
})
const powershell = ShellSelect.ps(shell)
const result = powershell ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
const name = ShellSelect.name(shell)
const result = powershell
? ShellScan.scanPowerShell(command)
: ShellScan.scan(command, name === "bash" || name === "zsh" ? name : "posix")
if (result.kind === "opaque")
return yield* Effect.fail(new Error(`Portable shell scanner cannot analyze command: ${result.reason}`))
@@ -225,15 +228,21 @@ export const scanPortable = Effect.fnUntraced(function* (command: string, shell:
if (CWD.has(name)) {
output.directories.push(
...directoryArgs(
words.flatMap((text): Part[] => {
const parameter = powershell ? /^(-(?:literalpath|path)):(.*)$/i.exec(text) : undefined
if (parameter)
return [
{ type: "command_parameter", text: parameter[1] },
{ type: "word", text: parameter[2] },
]
return [{ type: powershell && text.startsWith("-") ? "command_parameter" : "word", text }]
}),
powershell
? words.flatMap((text): Part[] => {
const parameter = /^(-(?:literalpath|path)):(.*)$/i.exec(text)
if (parameter)
return [
{ type: "command_parameter", text: parameter[1] },
{ type: "word", text: parameter[2] },
]
return [{ type: text.startsWith("-") ? "command_parameter" : "word", text }]
})
: item.rawWords.map((text, index) => ({
type: "word",
// Only literal quoting resolves to a static directory.
text: text.startsWith("$'") || (!/[$`~\\]/.test(text) && /['"]/.test(text)) ? item.words[index] : text,
})),
powershell,
cwd,
shell,
File diff suppressed because it is too large. Load diff
@@ -42,6 +42,21 @@ describe("ShellScan adversarial corpus", () => {
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
})
// Only shell sinks evaluate subscripts; ordinary arguments, heredoc bodies, and bound data without a sink are safe.
test.each([
"bun -e 'f(`a[${x}]`)'",
"rg 'a[$(x)]'",
"cat <<'EOF'\na[$(x)]\nEOF",
"echo 'a[$(x)]'",
'BODY="- [x] Fix \\`scan.ts\\`"; gh pr create --title "fix" --body "$BODY"',
"MSG='[feat] Fix `foo`'; git commit -m \"$MSG\"",
"PATTERN='a[${b}]'; rg \"$PATTERN\"",
"export REGEX='[0-9]+${foo}'",
"cat <<< 'const a = arr[0]; const b = `${a}`'",
])("scans subscript-shaped text outside shell sinks: %s", (input) => {
expect(ShellScan.scan(input).kind).toBe("scanned")
})
test.each(['printf "unterminated', "printf ok &&", "printf ok >", "echo > >out"])(
"keeps structurally uncertain Bash input opaque: %s",
(input) => {
@@ -49,6 +64,19 @@ describe("ShellScan adversarial corpus", () => {
},
)
test.each([
["repeated assignment value operators", "x[a]" + "=]".repeat(32_000)],
["unclosed assignment subscripts", "a[\n".repeat(21_000)],
["case patterns with substitutions", "case x in " + "[$(:)".repeat(10_000)],
["nested groups with bracket words", "{ ".repeat(31) + "echo " + "a[] ".repeat(15_000) + "; }".repeat(31)],
["continued heredoc lines", "cat <<E\n" + "x\\\n".repeat(20_000) + "E\n"],
["heredoc backslash runs", "cat <<E\n" + "\\".repeat(60_000) + "x\nE\n"],
])("scans adversarial Bash input in bounded time: %s", (_, input) => {
const start = performance.now()
ShellScan.scan(input)
expect(performance.now() - start).toBeLessThan(500)
})
test.each([
['pwsh --command "Remove-Item victim.txt"', ["pwsh"]],
["Import-Module ./evil.psm1", ["Import-Module"]],
@@ -60,23 +60,41 @@ const fixtures = [
["! scan_probe", ["scan_probe"]],
["time scan_probe", ["time"]],
["{fd}>/dev/null scan_probe", ["scan_probe"]],
["case $r in a) ls | head;; esac", ["ls", "head"]],
["case $r in a) ls && echo;; esac", ["ls", "echo"]],
["{ find . -exec echo {} \\; ; }", ["find"]],
["{ echo {a,{b,c}}; }", ["echo"]],
["if true; then\\\n echo hi; fi", ["true", "echo"]],
["for ((i=0; i<2; i++)) do echo hi; done", ["echo"]],
['echo "$(case x in @(a)) echo hi;; esac)"', ["echo", "echo"]],
["set -- 1; for x do scan_probe; done", ["set", "scan_probe"]],
["'q'; x=1 a", ["q", "a"]],
["cat <<E; ( true\nscan_ignored\nE\n)", ["cat", "true"]],
["cat <<E; f() { true\nscan_ignored\nE\n}; f", ["cat", "true", "f"]],
["cat <<E; case x in\nscan_ignored\nE\nx) scan_probe;; esac", ["cat", "scan_probe"]],
["time [[ ( -f foo ) ]]", []],
["time ! { echo a; echo b; }", ["echo", "echo"]],
] as const
describe("ordinary Bash and Zsh syntax", () => {
test.each(fixtures)("extracts actual command nodes: %s", (source, names) => {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
for (const dialect of ["bash", "zsh"] as const) {
const result = ShellScan.scan(source, dialect)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
}
})
for (const shell of ["bash", "zsh"]) {
const executable = Bun.which(shell)
for (const [source] of fixtures) {
// These are Bash spellings; Zsh's fd allocation is a standalone statement.
// These are Bash spellings; Zsh's fd allocation is a standalone statement. Bash parses extglob
// patterns only when extglob is enabled.
test.skipIf(
!executable ||
(shell === "zsh" && (source.includes('$"') || source.startsWith("{fd}") || source.includes("$["))),
(shell === "zsh" && (source.includes('$"') || source.startsWith("{fd}") || source.includes("$["))) ||
(shell === "bash" && source.includes("@(")),
)(`${shell} accepts the source grammar: ${source}`, () => {
const result = Bun.spawnSync([
executable ?? shell,
@@ -132,10 +150,15 @@ describe("ordinary Bash and Zsh syntax", () => {
"cat <<EOF\nunclosed",
"echo ${missing",
"echo $'missing",
"case $r in a) ls |;; esac",
])("rejects incomplete syntax: %s", (source) => {
expect(ShellScan.scan(source).kind).toBe("opaque")
})
test("scans a Zsh brace group closed after a redirect", () => {
expect(ShellScan.scan("{ a && >f }")).toMatchObject({ kind: "scanned", commands: [{ words: ["a"] }] })
})
test("preserves raw lexical spelling of ANSI-C and locale quoted words", () => {
expect(ShellScan.scan("$'pri\\x6etf' $'line\\n' $\"text\"")).toMatchObject({
kind: "scanned",
@@ -145,7 +168,7 @@ describe("ordinary Bash and Zsh syntax", () => {
test.each([
["coproc job { scan_probe; }", ["scan_probe"]],
["printf '%s' @(one|$(scan_probe))", ["printf", "scan_probe"]],
["case x in @(one|$(scan_probe))) ;; esac", ["scan_probe"]],
["printf '%s' $((1 + '$(scan_probe)'))", ["printf", "scan_probe"]],
["printf '%s' $(((1 + '$(scan_probe)')))", ["printf", "scan_probe"]],
['printf %s "${ scan_probe; }"', ["printf", "scan_probe"]],
@@ -241,3 +264,54 @@ describe("Bash shared heredoc delimiter grammar", () => {
)
})
})
describe("Bash dialects", () => {
const heads = (source: string, dialect?: ShellScan.Dialect) => {
const result = ShellScan.scan(source, dialect)
return result.kind === "scanned" ? result.commands.map((command) => command.words[0]) : result.kind
}
test("posix reports both readings of a double parenthesis", () => {
expect(heads("(( x = 1 )); y", "bash")).toEqual(["y"])
expect(heads("(( x = 1 )); y", "zsh")).toEqual(["y"])
expect(heads("(( x = 1 )); y", "posix")).toEqual(["x", "y"])
expect(heads("(( x = 1 )); y")).toEqual(["x", "y"])
expect(heads("(( (1) + (2) ))", "bash")).toEqual([])
expect(heads("(( (1) + (2) ))", "posix")).toBe("opaque")
})
test.each([
["true &>/dev/null next", ["true"], "opaque"],
["X=$[1 + 2] next", ["next"], "opaque"],
] as const)("reads Bash and Zsh syntax precisely where Dash diverges: %s", (source, precise, posix) => {
expect(heads(source, "bash")).toEqual([...precise])
expect(heads(source, "zsh")).toEqual([...precise])
expect(heads(source, "posix")).toEqual(posix)
})
test("reads a reserved word after a redirect as a Zsh keyword", () => {
const source = "if true; then >/dev/null fi; next"
expect(heads(source, "zsh")).toEqual(["true", "next"])
expect(heads(source, "bash")).toBe("opaque")
expect(heads(source, "posix")).toBe("opaque")
})
test("scans safe Zsh parameter flags, Zsh repeat loops, and Bash extglob arguments", () => {
expect(heads("print -l ${(M)files:#*.ts}", "zsh")).toEqual(["print"])
expect(heads("print -l ${(ps:\\n:)text}", "zsh")).toEqual(["print"])
expect(heads("repeat 3; do echo hi; done", "zsh")).toEqual(["echo"])
expect(heads("repeat 3; do echo hi; done", "posix")).toEqual(["echo"])
expect(heads("ls @(foo|bar)", "bash")).toEqual(["ls"])
})
test.each([
["/bin/bash", ["y"]],
["/usr/local/bin/zsh", ["y"]],
["/bin/sh", ["x = 1", "y"]],
["/bin/dash", ["x = 1", "y"]],
["/opt/bin/mksh", ["x = 1", "y"]],
] as const)("derives the dialect from the shell executable: %s", async (shell, resources) => {
const result = await Effect.runPromise(ShellParse.scanPortable("(( x = 1 )); y", shell, "/workspace"))
expect(result.commands.map((command) => command.resource)).toEqual([...resources])
})
})
@@ -0,0 +1,496 @@
import { afterAll, describe, expect, test } from "bun:test"
import { Effect, Exit } from "effect"
import fs from "fs"
import os from "os"
import path from "path"
import { ShellParse } from "../../src/shell/parse.js"
import { ShellScan } from "../../src/shell/scan.js"
const shellCandidates = ["/bin/bash", "/opt/homebrew/bin/bash", "/usr/local/bin/bash", "bash", "zsh", "dash"]
const shells = [
...new Set(
shellCandidates
.map((item) => (item.startsWith("/") ? (fs.existsSync(item) ? item : undefined) : Bun.which(item)))
.filter((item): item is string => Boolean(item)),
),
]
// Fixtures target specific shells and versions (bash 3.2 and 5.3, zsh, dash). Set SHELL_ORACLE_STRICT=1 on a
// machine with all of them to also verify that every fixture still executes somewhere.
const strict = process.env.SHELL_ORACLE_STRICT === "1"
const root = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-shell-oracle-"))
const bin = path.join(root, "bin")
const target = path.join(root, "target")
const log = path.join(root, "log")
fs.mkdirSync(bin)
fs.mkdirSync(target)
fs.writeFileSync(
path.join(bin, "scan_probe"),
'#!/bin/sh\nprintf \'%s\\n\' "scan_probe${1:+ $*}" >> "$SCAN_PROBE_LOG"\n',
{
mode: 0o755,
},
)
// Dash runs `scan_probe[x y]=1` as the command `scan_probe[x`.
fs.copyFileSync(path.join(bin, "scan_probe"), path.join(bin, "scan_probe[x"))
// Zsh treats a trailing parenthesized group after an existing file name as glob qualifiers.
fs.writeFileSync(path.join(root, "a="), "")
afterAll(() => fs.rmSync(root, { recursive: true, force: true }))
function shellFlags(executable: string) {
if (executable.endsWith("/bash")) return ["--noprofile", "--norc"]
if (executable.endsWith("/zsh")) return ["-f"]
return []
}
// Runs source in a real shell and returns the lines it logged.
function observe(executable: string, source: string) {
fs.writeFileSync(log, "")
Bun.spawnSync([executable, ...shellFlags(executable), "-c", source], {
cwd: root,
env: { PATH: `${bin}:/usr/bin:/bin`, HOME: root, LC_ALL: "C", SCAN_PROBE_LOG: log },
timeout: 2_000,
})
return fs
.readFileSync(log, "utf8")
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
}
// Each dialect must report what its shells run; posix, the default, covers every shell.
const dialects = {
bash: (executable: string) => path.basename(executable) === "bash",
zsh: (executable: string) => path.basename(executable) === "zsh",
posix: () => true,
} satisfies Record<ShellScan.Dialect, (executable: string) => boolean>
function expectProbesReported(source: string) {
const runs = shells.map((executable) => [executable, observe(executable, source)] as const)
if (strict)
expect(
runs.some(([, invocations]) => invocations.length > 0),
`Fixture never executed scan_probe in any real shell: ${source}`,
).toBe(true)
expect(ShellScan.scan(source)).toEqual(ShellScan.scan(source, "posix"))
for (const [dialect, runsIn] of Object.entries(dialects)) {
const result = ShellScan.scan(source, dialect as ShellScan.Dialect)
if (result.kind === "opaque") continue
const reported = result.commands.filter((command) => !command.declaration).map((command) => command.words.join(" "))
for (const [executable, invocations] of runs.filter(([executable]) => runsIn(executable)))
for (const invocation of invocations)
expect(reported, `${executable} executed ${invocation} with ${dialect} in: ${source}`).toContain(invocation)
}
}
// Each fixture runs scan_probe in at least one real shell, which the scanner must report or reject.
const fixtures = [
"a=(1)scan_probe",
"a=(\n1)scan_probe; ",
"a=(1)'scan_probe'",
"alias p=scan_probe\np",
"command alias p=scan_probe\np",
"x='*(e:scan_probe:)'; echo $^~x",
"x='$(scan_probe)'; echo \"${\\\n(e)x}\"",
"x='$(scan_probe)'; echo ${(j:):e)x}",
"x='*(e:scan_probe:)'; echo ${(f)~x}",
"x='*(e:scan_probe:)'; echo ${=~x}",
"x='*(e:scan_probe:)'; echo $=~x",
"x='$(scan_probe)'; echo ${(%e)x}",
"x='$(scan_probe)'; echo ${(s.:.e)x}",
"x='$(scan_probe)'; echo ${(s(:)e)x}",
"x='$(scan_probe)'; echo ${(ej: :)x}",
"x='*(e:scan_probe:)'; echo ${(s: :)~x}",
"x='*(e:scan_probe:)'; echo ${^~x}",
"printf -v x 'a[$(scan_probe)]'; echo $((x))",
"a=(1); getopts a: x -a 'a[$(scan_probe)]'; echo $((OPTARG))",
"command -- declare 'a[$(scan_probe)]=1'",
"a=(1); unset 'a[b[$\\\n(scan_probe)0]]'",
'declare \'a["\\"]"$(scan_probe)0]=1\'',
"declare -a 'a=(+ [$(scan_probe)]=1)'",
"a=(1); echo ${a[b[\\$(scan_probe)]]}",
'a["b[\\$(scan_probe)]"]=1',
'b=1; a["b[\\$(scan_probe)1]"]=1',
"a=(1); echo $[ ${x:-a[\\$(scan_probe)1]} ]",
"a=(1); s=abc; echo ${s:'a[$(scan_probe)0]'}",
"a=(1); s=abc; echo ${s:${x:-'a[$(scan_probe)1]'}}",
"read x <<< 'a[$(scan_probe f)]'; echo $((x))",
"mapfile -t a <<< 'b[$(scan_probe g)]'; echo $((a[0]))",
"x=1; echo $(( x[\\$(scan_probe i)] ))",
"a=(1); echo ${a[@]:'a[$(scan_probe k)]'}",
"builtin -- declare 'a[$(scan_probe)]=1'",
"command -p declare 'a[$(scan_probe)]=1'",
'echo "${unset:+${x[\'"\']}}"]}} \'$(scan_probe)\' " # "',
"echo \"${unset:+${x['}}\"']}}'; scan_probe # \"",
"echo \"${x:-$'\\''}\"; scan_probe # '\"",
"cat <<E\n${x:-'}'}' $(scan_probe)\nE",
'echo "${x:-a\'b}c\'d}"$(scan_probe)"\'"',
"echo \"${PATH//:/$'\\n'}\"; scan_probe ok",
"echo $(( : # ))'\n); scan_probe ) # '",
"echo $(( $(echo 1) # ))'\n); scan_probe ) # '",
"(( : # ))'\n); scan_probe ) # '",
"echo $(( 16#ff + 2#1 + $# + ${#x} )); scan_probe ok",
"(( scan_probe ))",
"(( (scan_probe) & (scan_probe) ))",
"(( (scan_probe)\n(scan_probe) ))",
"cat <<'}'; {\n:\n}\nscan_probe; cat <<'}'; }\n}",
"cat <<'x)'; case x in\nx)\nx) scan_probe; cat <<'x)'\nx)\n;; esac",
"cat <<'if'; f()\nif\nif scan_probe; cat <<'if'\nif\ntrue; then :; fi; f",
"cat <<E; ( true\nE\nscan_probe g\n)",
"cat <<E; { true\nE\nscan_probe g\n}",
"cat <<E; f() { true\nE\nscan_probe f\n}; f",
"cat <<E; if true\nE\nscan_probe i\nthen :; fi",
"cat <<E; echo $(true\nscan_probe s\n)\nE\nscan_probe after",
"cat <<E; echo `true\nscan_probe b\n`\nE\nscan_probe after",
"cat <<E; cat <(true\nscan_probe s\n)\nE\nscan_probe after",
"cat <<E; x=$(cat <<F\nF\n)\nE\nscan_probe out",
"{ cat <<E; }\nscan_probe x\nE\nscan_probe y",
"[[ a]]b# ]] && scan_probe",
'[[ "a]]"# ]] && scan_probe',
"[[ 'a]]'# ]] && scan_probe",
"[[ a]]b = a]]b ]] && scan_probe",
'{ export X="a"{}# ; scan_probe; }',
"{ export X=a}# ; scan_probe; }",
"{ case esac in (esac) scan_probe;; esac\n}",
"unset a[b\nscan_probe\necho ]+=1",
"export a[b; scan_probe; echo ]=1",
"a[b; scan_probe; echo ]=1",
"a[0 #]\n]=1; scan_probe",
"for i in 1; { scan_probe; }; while false; do export X=1; done",
"for ((i=0; i<1; i++)); { scan_probe; }; while false; do export X=1; done",
"for i in 1; scan_probe",
"((( echo '\"' ); scan_probe; ( echo '\"' )))",
"set=1; export X=${set:-${x['0\"0']}}; scan_probe; : '\"]}}' # '",
"export X=$$$$'\\'; scan_probe # '",
'export X="`export Y=\\"\'\\" ; scan_probe; export Z=\\"\'\\"`"',
'export X="`echo \\"(\\"; scan_probe; echo \\")\\"`"',
": <<-export\n\tex\\\n\tport\n$(scan_probe)\nexport",
'export X="${\\\n scan_probe; }"',
'export X="${\n scan_probe; }"',
'export X="${|\\\n REPLY=$(scan_probe); }"',
"!(scan_probe; true)",
"if !(scan_probe); then :; fi",
"f+() { scan_probe; }; f+",
': <<$"export"\n$export\nscan_probe\nexport',
': <<$"export"\nexport\nscan_probe\n$export',
"export X=$[1; scan_probe; : ]",
"X=$[1 scan_probe ]",
'export X=$["]"]; scan_probe # ]',
"export X=${#}# ; scan_probe",
"export X=$#a# ; scan_probe",
"export X=~+# ; scan_probe",
"{ export X=1; }# ; scan_probe; }",
"if true; then export X=1; fi# ; scan_probe; fi",
"for i in 1; do export X=1; done# ; scan_probe; done",
"case esac# in x) export X=1;; esac#) scan_probe;; esac",
": <<A <<B\nA\n$(scan_probe)\nB",
'export X="$(cat <<EOF\n)\nEOF\nscan_probe)"',
"cat <<EOF | scan_probe\nhello\nEOF",
"cat <<EOF && scan_probe\nhello\nEOF",
"cat <<'E'\"O\"F\n# $(scan_probe)\nEOF\nscan_probe",
"cat <<E\\\nOF\n$(scan_probe)\nEOF",
"cat <<-EOF\n\t EOF\n$(scan_probe)\n\tEOF",
"cat <<export\nex\\\nport\n# $(scan_probe)\nexport",
"cat <<< 'a'\"$(scan_probe)\"",
'export X=${x:-"}"}; scan_probe',
"export X=${x:-'}'}; scan_probe",
'export X="${x:-"}"}"; scan_probe',
'export X="${x:-"$(scan_probe)"}"',
"export X=${x//\\}/}; scan_probe; : }",
'export X="${x//\\}/}"; scan_probe; : "}"',
"export X=${x#${y}}; scan_probe",
'export X="${x#${y:-"}"}}"; scan_probe',
"export X=${!prefix*}; scan_probe",
"export X=${x@Q}; scan_probe",
"export X=\"${unset:+${x['\"']}}\"; scan_probe; : '\"}]}'",
'export X="${unset:+${x[\'}}\"; scan_probe; : \"\']}}"',
'echo $(case x in x) echo ")" ;; esac; scan_probe)',
"echo \"$(case x in (x) echo ')' ;; esac; scan_probe)\"",
'echo "`echo \\"$(scan_probe)\\"`"',
"echo `echo \\`scan_probe\\``",
"echo $( (echo a); scan_probe )",
"echo $((echo a); scan_probe)",
"if true; then scan_probe; elif true; then :; else :; fi",
"for i in in do done esac; do scan_probe; done",
"for do in 1; do scan_probe; done",
"for in in 1; do scan_probe; done",
"for x\nin 1; do scan_probe; done",
"set -- 1; for x\ndo scan_probe; done",
"set -- 1; for x do scan_probe; done",
"case in in in) scan_probe;; esac",
"case esac in a|esac) scan_probe;; esac",
"case y in x) ;; y) scan_probe;; esac",
"case x in x) scan_probe ;& y) : ;;& z) : ;; esac",
"case x in x) scan_probe ;| y) : ;; esac",
"case [ in [) scan_probe & ( scan_probe q ]) ;; esac",
"case x in (x|[) scan_probe & ( scan_probe q ]) ;; esac",
"while false; do :; done & scan_probe",
"{ scan_probe & }",
"( scan_probe & )",
"[[ b =~ b ]] && scan_probe",
"[[ ( a == a ) && ( b == b ) ]] && scan_probe",
'[[ "$(scan_probe)" == "]]" ]]',
"(( 1 + $(scan_probe) ))",
"(( a = 1 )) && scan_probe",
"(( (1) + (2) )); scan_probe",
"(( (echo a); scan_probe ))",
">/dev/null scan_probe",
"2>&1 scan_probe",
"A=1 >/dev/null B=2 scan_probe",
'export A=1 B="$(scan_probe)"',
'declare -a arr=(1 "$(scan_probe)")',
"export X=$'a'\\\n; scan_probe",
"case x in \\\nx) \\\nscan_probe;; \\\nesac",
"if true; then \\\nscan_probe; fi",
'() { :; } "$(scan_probe)"',
"function f() ( scan_probe ); f",
"{ export X=1 } && scan_probe",
"{ export X=1 } ; scan_probe ; }",
// Dialects disagree about reserved words after redirects and operators split by line continuations.
"if true; then >/dev/null fi; scan_probe; fi",
"if true; then >/dev/null fi; scan_probe",
"case x in x) >/dev/null esac; scan_probe; esac",
"true &>/dev/null scan_probe",
"true &>>/dev/null scan_probe",
"true &\\\n>/dev/null scan_probe",
"cat <<\\\n-EOF\nEOF\nscan_probe h1\n-EOF",
"cat <<\\\n-EOF\n-EOF\nscan_probe z\nEOF",
"cat <\\\n(scan_probe i)",
"[[ -n <\\\n(scan_probe c1) ]]",
"a=(<\\\n(scan_probe a1))",
"cat <<E\n$\\\n(scan_probe h)\nE",
"echo ${x:-$\\\n(scan_probe p)}",
"(( $\\\n(scan_probe a) ))",
"[[ $\\\n(scan_probe c) ]]",
"a=($\\\n(scan_probe arr))",
// Dash splits assignment subscripts at blanks.
"scan_probe[x y]=1",
"a[x '$(scan_probe)']=1",
"a[1 + $(scan_probe)]=1",
// Expansions inside parameter words, arithmetic, and subscripts.
"echo \"${x:-$'$(scan_probe q1)'}\"",
"echo ${x:-<(scan_probe p1)}",
"echo ${x:->(scan_probe g)}",
"x=${y:-<(scan_probe p4)}",
"[[ x == ${y:-<(scan_probe p5)} ]]",
"echo $(( $'$(scan_probe a)' ))",
"(( x = $'$(scan_probe b)' ))",
"echo $(( ${x:-'$(scan_probe a)'} ))",
"(( ${x:-'$(scan_probe b)'} ))",
"a[${x:-'$(scan_probe c)'}]=1",
"echo $[ ${x:-'$(scan_probe d)'} ]",
"echo $[ $'$(scan_probe h)' ]",
"echo ${a[${x:-'$(scan_probe e)'}]}",
"echo \"${a[${x:-'$(scan_probe f)'}]}\"",
"cat <<E\n${x:-'$(scan_probe h)'}\nE",
// Zsh glob qualifiers and extglob groups run code in globbed words.
"echo @(<(scan_probe e1))",
"printf '%s' @(one|$(scan_probe))",
"echo *(e:'scan_probe q1':)",
"echo *(+scan_probe)",
"a=(*(e:'scan_probe g':))",
"declare -a a=(*(e:'scan_probe g':))",
"export a=(*(e:'scan_probe h':))",
"for f in *(e:'scan_probe h':); do :; done",
"echo ${x:-target(e:'scan_probe p2':)}",
"echo ${x:-*(e:'scan_probe j':)}",
"echo a=(e:'scan_probe p3':)",
"echo >*(e:'scan_probe f':)",
"cat <*(e:'scan_probe g':)",
"echo $x*(e:'scan_probe h':)",
"echo \"\"*(e:'scan_probe i':)",
"echo {a,*(e:'scan_probe m':)}",
// Builtins and arithmetic evaluate subscripts in decoded literal text.
"declare -i x='a[$(scan_probe)]'",
"declare 'a[$(scan_probe)]=1'",
"declare -a 'a=([$(scan_probe)]=1)'",
"a=(1); unset 'a[$(scan_probe)]'",
'a=(1); unset "a[\\$(scan_probe)]"',
"[[ 'a[$(scan_probe)]' -eq 1 ]]",
"[[ -v 'a[$(scan_probe)]' ]]",
"read 'a[$(scan_probe)]' </dev/null",
"printf -v 'a[$(scan_probe)]' x",
"x='a[$(scan_probe)]'; echo $((x))",
"s=abc; x='a[$(scan_probe)0]'; printf '%s' \"${s:x}\"",
"ref='x[$(scan_probe)0]'; printf '%s' \"${!ref}\"",
"declare ${x:-'a[$(scan_probe)]=1'}",
'declare "${x:-a[\\$(scan_probe)]=1}"',
"read ${x:-'a[$(scan_probe)]'} </dev/null",
"declare \"$(echo 'a[$(scan_probe)]=1')\"",
"declare \"${ echo a; }\"'[$(scan_probe)]=1'",
"n=a; declare \"$n\"'[$(scan_probe)]=1'",
"declare \"$(echo a)\"'[$(scan_probe)]=1'",
"builtin declare 'a[$(scan_probe)]=1'",
"printf -v'a[$(scan_probe)]' x",
"set -- 'a[$(scan_probe)]'; echo $(($1))",
"for x in 'a[$(scan_probe)]'; do echo $((x)); done",
"a=(1 'a[$(scan_probe)]'); echo $((a[1]))",
"x='a[$(scan_probe)]' eval 'echo $((x))'",
"a=(1); echo $(( a[\\$(scan_probe)] ))",
"a=(1); (( a[\\$(scan_probe)] ))",
// Explicit evaluation operators.
"x='$(scan_probe)'; echo ${x@P}",
"x='$(scan_probe)'; echo \"${x@P}\"",
"x='$(scan_probe)'; echo ${(e)x}",
"x='$(scan_probe)'; echo ${(ee)x}",
"echo ${(e):-'$(scan_probe)'}",
"x='*(e:scan_probe:)'; echo ${~x}",
"x='*(e:scan_probe:)'; echo $~x",
// Precommand modifiers, bundled/ordered binding options, repeat, compgen, and setopt.
"noglob typeset 'a[$(scan_probe)]=1'",
"nocorrect typeset 'a[$(scan_probe)]=1'",
"a=(1); noglob unset 'a[$(scan_probe)]'",
"noglob read 'a[$(scan_probe)]' <<< x",
'a=(1); noglob let "a[\\$(scan_probe)]"',
"print -r -v 'a[$(scan_probe)]' x",
"print -rv 'a[$(scan_probe)]' x",
"a=(1); print -r -v x 'a[$(scan_probe)]'; echo $((x))",
"a=(1); print -rv x 'a[$(scan_probe)]'; echo $((x))",
": & wait -np 'a[$(scan_probe)]'",
": & wait -n -p'a[$(scan_probe)]'",
": & wait -fp 'a[$(scan_probe)]' $!",
"time declare 'a[$(scan_probe)]=1'",
"coproc declare 'a[$(scan_probe)]=1'",
"a=(1); time unset 'a[$(scan_probe)]'",
"a=(1); time let 'a[$(scan_probe)]'",
"a=(1); time [[ -v 'a[$(scan_probe)]' ]]",
"a=(1); time [[ 'a[$(scan_probe)]' -eq 1 ]]",
"a=(1); coproc [[ -v 'a[$(scan_probe)]' ]]",
'export X="${ time ! { :; }; scan_probe; }"',
'export X="${ time\\\n { :; }; scan_probe; }"',
'export X="${ time -- { :; }; scan_probe; }"',
"repeat 1 scan_probe",
"a=(1); repeat 'a[$(scan_probe)]' :",
"setopt globsubst; x='*(e:scan_probe:)'; echo $x",
"set -o globsubst; x='*(e:scan_probe:)'; echo $x",
"compgen -C scan_probe",
"compgen -C 'scan_probe'",
// Combined literal/output subscripts, deferred bindings, continuations, Zsh flags, and ANSI-C escapes.
"declare \"a[$(echo '$(scan_probe)')]=1\"",
"declare \"$(echo 'a[')\"'$(scan_probe)]=1'",
"a=(1); unset \"a[$(echo '$(scan_probe)')]\"",
"a=(1); echo $(( a[$(echo '$(scan_probe)')] ))",
"x='$(scan_probe)'; declare \"a[$x]=1\"",
"x='$(scan_probe)'; a=(1); unset \"a[$x]\"",
"x='$(scan_probe)'; let \"a[$x]=1\"",
"x='$(scan_probe)'; read \"a[$x]\" <<< 1",
"x='$(scan_probe)'; printf -v \"a[$x]\" 1",
"a=(1); x='$(scan_probe)'; echo $((a[$x]))",
"a=(1); x='$(scan_probe)'; [[ -v \"a[$x]\" ]]",
"a=(1); x='$(scan_probe)'; [[ \"a[$x]\" -eq 1 ]]",
"a=(1); echo ${a\\\n['$(scan_probe)']}",
"a=(1); s=abc; echo ${s\\\n:'a[$(scan_probe)0]'}",
"a=(1); echo ${a[0]\\\n:'a[$(scan_probe)0]'}",
"x='$(scan_probe)'; echo ${x@\\\nP}",
"x='$(scan_probe)'; echo \"${x@\\\nP}\"",
"x='$(scan_probe)'; echo ${x@P\\\n}",
"x='*(e:scan_probe:)'; echo $\\\n~x",
"x='*(e:scan_probe:)'; echo $^\\\n~x",
"x='*(e:scan_probe:)'; echo ${^\\\n~x}",
"x='*(e:scan_probe:)'; echo ${(f)\\\n~x}",
"a=(1); echo ${(f)a['a[$(scan_probe)]']}",
"a=(1); echo ${=a['a[$(scan_probe)]']}",
"a=(1); echo ${^a['a[$(scan_probe)]']}",
"a=(1); s=abc; echo ${(f)s:'a[$(scan_probe)0]'}",
"a=(1); s=abc; echo ${=s:'a[$(scan_probe)0]'}",
"a=(1); s=abc; echo ${^s:'a[$(scan_probe)0]'}",
"a=(1); [[ x != *(a)]] && 0 -eq 'a[$(scan_probe)]' ]]",
"a=(1); [[ x != *(a)]] && -v 'a[$(scan_probe)]' ]]",
"a=(1); [[ x != (a)]] && 0 -eq 'a[$(scan_probe)]' ]]",
"shopt -s extglob\na=(1); [[ x != *(a)]] && 0 -eq 'a[$(scan_probe)]' ]]",
"a=(1); echo $(( $'a\\x5b\\x24\\x28scan_probe\\x29\\x5d' ))",
"a=(1); (( $'a\\x5b\\x24\\x28scan_probe\\x29\\x5d' ))",
"a=(1); echo ${a[$'a\\x5b\\x24\\x28scan_probe\\x29\\x5d']}",
"a=(1); declare \"${x:-$'a\\x5b\\x24\\x28scan_probe\\x29\\x5d=1'}\"",
"a=(1); unset \"${x:-$'a\\x5b\\x24\\x28scan_probe\\x29\\x5d'}\"",
"(( $'\\' )) # ' )); scan_probe",
"declare -A a; a[$'\\']=1 # ']=1; scan_probe",
"a=(1); echo ${a[$'\\']} # ']} $(scan_probe)",
": <<$'export'\n$export\nscan_probe\nexport",
": <<$'EOF'\n$EOF\nscan_probe\nEOF",
"echo $'\\'; scan_probe; : ' # '",
"export X=$'\\'; scan_probe; : ' # '",
"export X=$'\\c\\\\'; scan_probe # '",
"export X=$'\\c'; scan_probe; : ' # '",
"$'scan_probe\\0x'",
"$'scan_probe\\x00x'",
"$'scan_probe\\u0000x'",
"$'scan_probe\\c@x'",
"$'declare\\0x' 'a[$(scan_probe)]=1'",
"a=(1); $'unset\\0x' 'a[$(scan_probe)]'",
"$'let\\0x' 'a[$(scan_probe)]'",
"x='x y'; scan_probe[$x]=1",
] as const
// These also run inside every wrapper below.
const nestedFixtures = [
"[[ a]]# ]] && scan_probe",
"{ export X={}# ; scan_probe; }",
"unset a[b; scan_probe; echo ]=1",
"case esac in (esac) scan_probe;; esac",
"case esac in (a|esac) scan_probe;; esac",
"for i in 1; { if true; then scan_probe; fi }; while false; do export X=1; done",
"(( echo '\"' ); scan_probe; ( echo '\"' ))",
"export X=${unset:+${x['0\"0']}}; scan_probe; : '\"]}}' # '",
"export X=$$'\\'; scan_probe # '",
": <<-export\n\tex\\\n\tport\n# $(scan_probe)\nexport",
"!(scan_probe)",
"f+() case x in x) scan_probe;; esac; f+",
"f@g() case x in x) scan_probe;; esac; f@g",
"f@g() for i in 1; do scan_probe; done; f@g",
] as const
const wrappers: Array<[name: string, wrap: (inner: string) => string]> = [
["$(...)", (inner) => `export OUTER=$( ${inner}\n)`],
['"$(...)"', (inner) => `export OUTER="$( ${inner}\n)"`],
["backticks", (inner) => `export OUTER=\` ${inner}\n\``],
["heredoc", (inner) => `: <<EOF\n$( ${inner}\n)\nEOF`],
["case arm", (inner) => `case x in x) ${inner}\n;; esac`],
["for loop", (inner) => `for k in 1; do ${inner}\ndone`],
["function", (inner) => `wrap_fn() {\n${inner}\n}; wrap_fn`],
["brace group", (inner) => `{ ${inner}\n}`],
]
describe.skipIf(process.platform === "win32")("real-shell soundness oracle", () => {
test("discovers at least bash on PATH", () => {
expect(shells.some((item) => item.endsWith("/bash"))).toBe(true)
})
test.each([...fixtures, ...nestedFixtures])("reports or rejects real-shell execution: %j", (source) => {
expectProbesReported(source)
})
test.each(wrappers.flatMap(([name, wrap]) => nestedFixtures.map((source) => [name, source, wrap(source)])))(
"reports or rejects in %s: %j",
(_, __, source) => {
expectProbesReported(source)
},
)
})
describe.skipIf(process.platform === "win32")("real-shell directory oracle", () => {
test.each(["cd >/dev/null TARGET", "cd 2>/dev/null TARGET", "cd $'TARGET'", 'cd "TARGET"'])(
"reports the directory a real shell changes to: %s",
async (template) => {
const source = template.replace("TARGET", target)
const targets = [target, fs.realpathSync(target)]
const changed = shells.filter((executable) =>
targets.includes(observe(executable, `${source}\npwd >> "$SCAN_PROBE_LOG"`).at(-1) ?? ""),
)
expect(changed.length, `Fixture never changed directory in any real shell: ${source}`).toBeGreaterThan(0)
for (const executable of changed) {
const parsed = await Effect.runPromiseExit(ShellParse.scanPortable(source, executable, root))
if (Exit.isSuccess(parsed)) expect(parsed.value.directories, `${executable} in: ${source}`).toContain(target)
}
},
)
})
+13 -1
View File
@@ -38,6 +38,11 @@ describe("Bash redirect resource oracle", () => {
`${redirect} FOO=bar git status 3>tail`,
`npm run ${redirect} test`,
]) {
// Dash reads `&>` as `&` and `>`, so words after its target start another command there.
if (redirect.startsWith("&") && !command.endsWith(redirect)) {
expect(ShellScan.scan(command).kind).toBe("opaque")
continue
}
await parity(command)
for (const separator of separators) {
await parity(`printf ok${separator}${command}`)
@@ -80,7 +85,6 @@ describe("Bash redirect resource oracle", () => {
"pwd | cat 2\\>out",
"if true; then printf ok && cat >$(printf path); fi",
"if true; then printf ok && git >out status; else cat >log; fi",
"pwd && cd >out /outside",
"time git status",
"time -p git status",
"coproc git status",
@@ -151,4 +155,12 @@ describe("Bash redirect resource oracle", () => {
{ resource: "FOO=bar >output git status", save: "git status *" },
])
})
test("known gap: redirect before cd operand retains the target directory natively", async () => {
const source = "pwd && cd >out /outside"
const legacy = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
const native = await Effect.runPromise(ShellParse.scanPortable(source, "/bin/bash", "/workspace"))
expect(legacy).toEqual({ commands: [{ resource: "pwd", save: "pwd *" }], directories: [] })
expect(native).toEqual({ commands: [{ resource: "pwd", save: "pwd *" }], directories: ["/outside"] })
})
})
@@ -234,8 +234,6 @@ describe("ShellScan", () => {
"{fd}>/tmp/log touch /tmp/victim",
"time touch /tmp/victim",
"printf '%s' \"$(printf safe ${x%)}; touch /tmp/victim)\"",
"s=abc; x='a[$(touch /tmp/victim)0]'; printf '%s' \"${s:x}\"",
"ref='x[$(touch /tmp/victim)0]'; printf '%s' \"${!ref}\"",
"if true; then echo safe; fi > /tmp/victim",
"if true; then :; 'if' victim; fi",
])("scans Bash lexical forms without interpreting shell values: %s", (command) => {