Skip to content

Commit 63ea71f

Browse files
authored
Document and fix command sanitizing with shlex.split (#3245)
2 parents 1458800 + b96c260 commit 63ea71f

3 files changed

Lines changed: 170 additions & 14 deletions

File tree

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ Released 2026-04-02
1717
with a dedicated CI job. :pr:`3139`
1818
- Fix callable ``flag_value`` being instantiated when used as a default via
1919
``default=True``. :issue:`3121` :pr:`3201` :pr:`3213` :pr:`3225`
20+
- Use :func:`shlex.split` to split pager and editor commands into ``argv``
21+
lists for :class:`subprocess.Popen`, removing ``shell=True``.
22+
:issue:`1026` :pr:`1477` :pr:`2775`
2023

2124
Version 8.3.1
2225
--------------

src/click/_termui_impl.py

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,21 @@ def generator(self) -> cabc.Iterator[V]:
367367

368368

369369
def pager(generator: cabc.Iterable[str], color: bool | None = None) -> None:
370-
"""Decide what method to use for paging through text."""
370+
"""Decide what method to use for paging through text.
371+
372+
The ``PAGER`` environment variable is split into an ``argv`` list with
373+
:func:`shlex.split` in its default POSIX mode so that quotes are
374+
stripped from tokens and quoted Windows paths are preserved.
375+
376+
.. note::
377+
``posix=False`` `was considered but rejected
378+
<https://github.com/pallets/click/pull/1477#issuecomment-620231711>`_
379+
because it retains quote characters in tokens. The
380+
:func:`shlex.quote` approach was also reverted in :pr:`1543`.
381+
382+
.. seealso::
383+
:issue:`1026`, :pr:`1477` and :pr:`2775`.
384+
"""
371385
stdout = _default_text_stdout()
372386

373387
# There are no standard streams attached to write to. For example,
@@ -378,8 +392,7 @@ def pager(generator: cabc.Iterable[str], color: bool | None = None) -> None:
378392
if not isatty(sys.stdin) or not isatty(stdout):
379393
return _nullpager(stdout, generator, color)
380394

381-
# Split and normalize the pager command into parts.
382-
pager_cmd_parts = shlex.split(os.environ.get("PAGER", ""), posix=False)
395+
pager_cmd_parts = shlex.split(os.environ.get("PAGER", ""))
383396
if pager_cmd_parts:
384397
if WIN:
385398
if _tempfilepager(generator, pager_cmd_parts, color):
@@ -411,11 +424,24 @@ def pager(generator: cabc.Iterable[str], color: bool | None = None) -> None:
411424
def _pipepager(
412425
generator: cabc.Iterable[str], cmd_parts: list[str], color: bool | None
413426
) -> bool:
414-
"""Page through text by feeding it to another program. Invoking a
415-
pager through this might support colors.
427+
"""Page through text by feeding it to another program.
428+
429+
Invokes the pager via :class:`subprocess.Popen` with an ``argv`` list
430+
produced by :func:`shlex.split`. The command is resolved to an absolute
431+
path with :func:`shutil.which` as recommended by the
432+
:mod:`subprocess` docs for Windows compatibility.
433+
434+
Invoking a pager through this might support colors: if piping to
435+
``less`` and the user hasn't decided on colors, ``LESS=-R`` is set
436+
automatically.
437+
438+
Returns ``True`` if the command was found and executed, ``False``
439+
otherwise so another pager can be attempted.
416440
417-
Returns `True` if the command was found, `False` otherwise and thus another
418-
pager should be attempted.
441+
.. seealso::
442+
:pr:`2775` improved error handling: :exc:`BrokenPipeError` is
443+
caught specifically, generator exceptions terminate the pager, and
444+
``stdin.close()`` is always called in a ``finally`` block.
419445
"""
420446
# Split the command into the invoked CLI and its parameters.
421447
if not cmd_parts:
@@ -509,8 +535,13 @@ def _tempfilepager(
509535
) -> bool:
510536
"""Page through text by invoking a program on a temporary file.
511537
512-
Returns `True` if the command was found, `False` otherwise and thus another
513-
pager should be attempted.
538+
Used as the primary pager strategy on Windows (where piping to
539+
``more`` adds spurious ``\\r\\n``), and as a fallback on other
540+
platforms. The command is resolved to an absolute path with
541+
:func:`shutil.which`.
542+
543+
Returns ``True`` if the command was found and executed, ``False``
544+
otherwise so another pager can be attempted.
514545
"""
515546
# Split the command into the invoked CLI and its parameters.
516547
if not cmd_parts:
@@ -592,6 +623,15 @@ def get_editor(self) -> str:
592623
return "vi"
593624

594625
def edit_files(self, filenames: cabc.Iterable[str]) -> None:
626+
"""Open files in the user's editor.
627+
628+
The editor command is split into an ``argv`` list with
629+
:func:`shlex.split` in POSIX mode; see :func:`pager` for rationale.
630+
631+
.. seealso::
632+
:issue:`1026` and :pr:`1477`.
633+
"""
634+
import shlex
595635
import subprocess
596636

597637
editor = self.get_editor()
@@ -601,11 +641,10 @@ def edit_files(self, filenames: cabc.Iterable[str]) -> None:
601641
environ = os.environ.copy()
602642
environ.update(self.env)
603643

604-
exc_filename = " ".join(f'"{filename}"' for filename in filenames)
605-
606644
try:
607645
c = subprocess.Popen(
608-
args=f"{editor} {exc_filename}", env=environ, shell=True
646+
args=shlex.split(editor) + list(filenames),
647+
env=environ,
609648
)
610649
exit_code = c.wait()
611650
if exit_code != 0:
@@ -754,8 +793,6 @@ def _translate_ch_to_exc(ch: str) -> None:
754793
if ch == "\x1a" and WIN: # Windows, Ctrl+Z
755794
raise EOFError()
756795

757-
return None
758-
759796

760797
if sys.platform == "win32":
761798
import msvcrt

tests/test_termui.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import platform
22
import tempfile
33
import time
4+
from unittest.mock import patch
45

56
import pytest
67

78
import click._termui_impl
89
from click._compat import WIN
10+
from click._termui_impl import Editor
911
from click.exceptions import BadParameter
1012
from click.exceptions import MissingParameter
1113

@@ -404,6 +406,120 @@ def test_edit(runner):
404406
assert reopened_file.read() == "aTest\nbTest\n"
405407

406408

409+
@pytest.mark.parametrize(
410+
("editor_cmd", "filenames", "expected_args"),
411+
[
412+
pytest.param(
413+
"myeditor --wait --flag",
414+
["file1.txt", "file2.txt"],
415+
["myeditor", "--wait", "--flag", "file1.txt", "file2.txt"],
416+
id="editor with args",
417+
),
418+
pytest.param(
419+
"vi",
420+
['file"; rm -rf / ; echo "'],
421+
["vi", 'file"; rm -rf / ; echo "'],
422+
id="shell metacharacters in filename",
423+
),
424+
# Issue #1026: editor path with spaces must be quoted.
425+
pytest.param(
426+
'"C:\\Program Files\\Sublime Text 3\\sublime_text.exe"',
427+
["f.txt"],
428+
["C:\\Program Files\\Sublime Text 3\\sublime_text.exe", "f.txt"],
429+
id="quoted windows path with spaces (issue 1026)",
430+
),
431+
# PR #1477: pager/editor command with flags, like ``less -FRSX``.
432+
pytest.param(
433+
"less -FRSX",
434+
["f.txt"],
435+
["less", "-FRSX", "f.txt"],
436+
id="command with flags (pr 1477)",
437+
),
438+
# Issue #1026: quoted command with ``--wait`` flag.
439+
pytest.param(
440+
'"my command" --option value arg',
441+
["f.txt"],
442+
["my command", "--option", "value", "arg", "f.txt"],
443+
id="quoted command with args (issue 1026)",
444+
),
445+
# PR #1477: unquoted unix path.
446+
pytest.param(
447+
"/usr/bin/vim",
448+
["f.txt"],
449+
["/usr/bin/vim", "f.txt"],
450+
id="unix absolute path",
451+
),
452+
# Issue #1026: macOS path with escaped space.
453+
pytest.param(
454+
"/Applications/Sublime\\ Text.app/Contents/SharedSupport/bin/subl",
455+
["f.txt"],
456+
["/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl", "f.txt"],
457+
id="escaped space in unix path (issue 1026)",
458+
),
459+
],
460+
)
461+
def test_editor_path_normalization(editor_cmd, filenames, expected_args):
462+
with patch("subprocess.Popen") as mock_popen:
463+
mock_popen.return_value.wait.return_value = 0
464+
Editor(editor=editor_cmd).edit_files(filenames)
465+
466+
mock_popen.assert_called_once()
467+
args = mock_popen.call_args[1].get("args") or mock_popen.call_args[0][0]
468+
assert args == expected_args
469+
assert mock_popen.call_args[1].get("shell") is None
470+
471+
472+
@pytest.mark.skipif(not WIN, reason="Windows-specific editor paths")
473+
@pytest.mark.parametrize(
474+
("editor_cmd", "expected_cmd"),
475+
[
476+
pytest.param(
477+
"notepad",
478+
["notepad"],
479+
id="plain notepad",
480+
),
481+
pytest.param(
482+
'"C:\\Program Files\\Sublime Text 3\\sublime_text.exe" --wait',
483+
["C:\\Program Files\\Sublime Text 3\\sublime_text.exe", "--wait"],
484+
id="quoted path with flag",
485+
),
486+
],
487+
)
488+
def test_editor_windows_path_normalization(editor_cmd, expected_cmd):
489+
"""Windows-specific tests: verify ``Popen`` receives unquoted paths that
490+
``subprocess.list2cmdline`` can re-quote for ``CreateProcess``."""
491+
with patch("subprocess.Popen") as mock_popen:
492+
mock_popen.return_value.wait.return_value = 0
493+
Editor(editor=editor_cmd).edit_files(["f.txt"])
494+
495+
args = mock_popen.call_args[1].get("args") or mock_popen.call_args[0][0]
496+
assert args == expected_cmd + ["f.txt"]
497+
assert mock_popen.call_args[1].get("shell") is None
498+
499+
500+
def test_editor_env_passed_through():
501+
with patch("subprocess.Popen") as mock_popen:
502+
mock_popen.return_value.wait.return_value = 0
503+
Editor(editor="vi", env={"MY_VAR": "1"}).edit_files(["f.txt"])
504+
505+
env = mock_popen.call_args[1].get("env")
506+
assert env is not None
507+
assert env["MY_VAR"] == "1"
508+
509+
510+
def test_editor_failure_exception():
511+
with patch("subprocess.Popen") as mock_popen:
512+
mock_popen.return_value.wait.return_value = 1
513+
with pytest.raises(click.ClickException, match="Editing failed"):
514+
Editor(editor="vi").edit_files(["f.txt"])
515+
516+
517+
def test_editor_nonexistent_exception():
518+
with patch("subprocess.Popen", side_effect=OSError("not found")):
519+
with pytest.raises(click.ClickException, match="not found"):
520+
Editor(editor="nonexistent").edit_files(["f.txt"])
521+
522+
407523
@pytest.mark.parametrize(
408524
("prompt_required", "required", "args", "expect"),
409525
[

0 commit comments

Comments
 (0)