Skip to content

fix: quote empty arguments and escape apostrophes when building a shell command line - #2711

Draft
acinader wants to merge 3 commits into
plaintextaccounting:mainfrom
acinader:quote-empty-arg
Draft

fix: quote empty arguments and escape apostrophes when building a shell command line#2711
acinader wants to merge 3 commits into
plaintextaccounting:mainfrom
acinader:quote-empty-arg

Conversation

@acinader

@acinader acinader commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

quoteForCommandLine has two defects, both of which lose or mangle an argument on
its way to a shell command line.

Empty arguments vanish. It returns a string unchanged when it contains no
quote, whitespace or shell character. An empty string contains none, so it comes
back unquoted and unwords drops it:

unwords (map quoteForCommandLine ["a", "", "b"])  ==  "a  b"

Apostrophes break the quoting. When an argument does need quoting it is
wrapped in single quotes, and escapeSingleQuotes escaped an embedded apostrophe
as \'. A backslash is not an escape inside single quotes in POSIX sh, so the
quote closes at the apostrophe and the line no longer parses:

argument   it's

before     sent to sh as  'it\'s'    -> unterminated quote, sh rejects the line
after      sent to sh as  'it'\''s'  -> addon receives [it's]

Both are fixed in quoteForCommandLine itself: an empty string becomes '', and
an apostrophe uses the POSIX idiom of closing the quote, emitting it, and
reopening.

What this affects

Every caller that builds a shell command line rather than passing an argv list:
Run.hs for addons under run and repl, and the ! shell alias path in
Cli.hs.

$ cat cmds.txt
zzzz a "" b

before   hledger run -f /dev/null cmds.txt    argv: [-f] [/dev/null] [a] [b]
after                                         argv: [-f] [/dev/null] [a] [] [b]

An apostrophe in a payee or description is common enough to hit in ordinary use —
hledger ui "Trader Joe's" fails on main.

Windows

This does not close the Windows gap. quoteForCommandLine has no platform branch,
and cmd.exe does not recognise single quotes as a quoting character —
shellQuoteIfNeeded's docstring says exactly that, and uses double-quote escaping
there instead. So on Windows '' would arrive as two literal characters rather
than an empty argument. Closing that would mean making quoteForCommandLine
platform-aware.

I have a CI probe showing empty arguments survive on Windows when quoted as "",
but that is a different form from what this PR emits, so I am not claiming the
Windows half is fixed. I'll confirm the actual behaviour separately.

Tests

run.test 31 covers an empty argument through run, 32 through repl, and 33 an
argument containing an apostrophe. A doctest covers quoteForCommandLine directly.
All fail on main and pass with the fix.

Full functional suite 1735/1735, doctests 297/297, builds warning-free under -Werror.

Provenance

Found while testing #2699, but independent of it: the defect is in hledger-lib
and predates that PR. It stands on its own against main and can land in any order.

The apostrophe half was also reached independently in the review session linked
from #2699 (finding 5), which suggested fixing both in quoteForCommandLine
together rather than special-casing them at the call sites — which is what this
now does.

Ready for merge but marked draft to comply with pr policy.

AI usage: Claude Opus 5, ~15k output tokens

quoteForCommandLine returns a string unchanged when it contains no quote,
whitespace or shell characters. An empty string contains none, so it comes
back unquoted, and unwords drops it:

  unwords (map quoteForCommandLine ["a", "", "b"])  ==  "a  b"

Every caller that builds a shell command line loses empty arguments as a
result. Run.hs does, for addon commands:

  $ cat cmds.txt
  zzzz a "" b

  before  hledger run -f /dev/null cmds.txt   argv: [-f] [/dev/null] [a] [b]
  after                                       argv: [-f] [/dev/null] [a] [] [b]

Quote the empty string.

New case 31 in run.test covers the addon path.

AI usage: Claude Opus 5, ~9k output tokens
repl dispatches addons the same way run does, so it lost empty arguments
for the same reason. Case 32 covers it.

AI usage: Claude Opus 5, ~3k output tokens
@acinader

acinader commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

find a way to test this on windows

quoteForCommandLine wraps an argument in single quotes when it contains a
quote, whitespace or shell character, and escapeSingleQuotes escaped an
embedded apostrophe as \'. A backslash is not an escape inside single quotes
in POSIX sh, so the quote closes at the apostrophe and the rest of the
argument is no longer carried as one argument:

  argument   it's

  before     sent to sh as  'it\'s'    -> unterminated quote, sh rejects the line
  after      sent to sh as  'it'\''s'  -> addon receives [it's]

Use the POSIX idiom instead: close the quote, emit the apostrophe, reopen it.

This affects the callers that still build a shell command line rather than
passing an argv list: Run.hs for addons under run and repl, and the !
shell alias path in Cli.hs. An apostrophe in a payee or description is
common enough to hit in normal use, eg `hledger ui "Trader Joe's"`.

New case 33 in run.test covers the run path; a doctest covers
quoteForCommandLine directly.

AI usage: Claude Opus 5, ~3k output tokens
@acinader acinader changed the title fix: quote an empty argument for the command line fix: quote empty arguments and escape apostrophes when building a shell command line Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant