From c27c46b78bafd0c63803c35229bb70b444beb5e7 Mon Sep 17 00:00:00 2001 From: Derek Lewis Date: Sat, 5 Sep 2026 03:34:52 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=F0=9F=94=A7=EF=BC=9Astop?= =?UTF-8?q?=20a=20filename=20being=20read=20as=20an=20option?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quoting settles what the shell does with a name and nothing about what the tool then makes of it. `'--write.md'` reaches prettier as `--write.md`, and prettier reads it as an option: it printed `Cannot read properties of null` and exited 0. A check that passed having checked nothing, which is worse than one that failed. A leading `./` says the argument is a path. It costs a relative name two characters and only the names that need it get them. Demonstrated on a file called `--write.md` holding deliberately bad Markdown. Before: prettier errored and the task exited 0. After: prettier reports the file, and markdownlint reports MD019 inside it. This is the residue of the same defect as the quoting itself -- a filename is attacker-chosen input that reaches a command line, and there were two ways for it to matter rather than one. Signed-off-by: Derek Lewis Assisted-by: Claude-Code:claude-opus-5 --- build/utils.mts | 12 +++++++++++- build/utils.test.mts | 11 +++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/build/utils.mts b/build/utils.mts index 4cbe1ccc..77d05512 100644 --- a/build/utils.mts +++ b/build/utils.mts @@ -38,7 +38,17 @@ export const exec = catchWrap(execute, 99); export const quote = (paths: string | string[]) => [paths] .flat() - .map((path) => `'${path.replaceAll("'", "'\\''")}'`) + .map((path) => { + // Quoting settles what the shell does with a name and nothing about + // what the tool then makes of it: `'--write.md'` arrives at prettier as + // `--write.md`, which it reads as an option. It answered that one by + // printing an error and exiting 0 -- a check that passed having checked + // nothing. A leading `./` says the argument is a path and costs a + // relative name two characters. + const safe = path.startsWith('-') ? `./${path}` : path; + + return `'${safe.replaceAll("'", "'\\''")}'`; + }) .join(' '); /** diff --git a/build/utils.test.mts b/build/utils.test.mts index 8c674191..7752cee2 100644 --- a/build/utils.test.mts +++ b/build/utils.test.mts @@ -140,6 +140,17 @@ describe('quote', () => { deepStrictEqual(quote("it's.md"), "'it'\\''s.md'"); }); + test('keeps a name that looks like an option from being read as one', () => { + // Quoting alone leaves `--write.md` arriving at the tool as `--write.md`. + deepStrictEqual(quote('--write.md'), "'./--write.md'"); + deepStrictEqual(quote('-'), "'./-'"); + }); + + test('leaves an ordinary path alone', () => { + deepStrictEqual(quote('doc/adr/0001-a.md'), "'doc/adr/0001-a.md'"); + deepStrictEqual(quote('/abs/path.jar'), "'/abs/path.jar'"); + }); + test('survives a round trip through the shell it is written for', async () => { // The escaping is only worth anything if the shell `exec` uses agrees // with it, so this asks that shell rather than a model of it.