Skip to content

Commit e676a74

Browse files
committed
Enhance PostgreSQLExecutor to escape apostrophes in unixsocketdir
- Updated the command generation in `PostgreSQLExecutor` to escape single quotes in the `unixsocketdir` parameter, ensuring valid syntax for PostgreSQL GUC strings. - Refactored the `running()` method to use `subprocess.run` instead of `subprocess.getstatusoutput`, improving compatibility and security by avoiding shell parsing. - Added regression tests in `test_windows_compatibility.py` to verify correct escaping of apostrophes in `unixsocketdir` and ensure robust command generation.
1 parent bd12358 commit e676a74

2 files changed

Lines changed: 107 additions & 34 deletions

File tree

pytest_postgresql/executor.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,11 +135,15 @@ def __init__(
135135
command_template = self.WINDOWS_PROC_START_COMMAND
136136
else:
137137
command_template = self.UNIX_PROC_START_COMMAND
138+
# PostgreSQL GUC single-quoted strings double single-quotes to escape them
139+
# (e.g. /tmp/o'hare → /tmp/o''hare). Apply this before interpolation so
140+
# the generated unix_socket_directories value is always syntactically valid.
141+
escaped_unixsocketdir = self.unixsocketdir.replace("'", "''")
138142
command = command_template.format(
139143
executable=self.executable,
140144
datadir=self.datadir,
141145
port=port,
142-
unixsocketdir=self.unixsocketdir,
146+
unixsocketdir=escaped_unixsocketdir,
143147
logfile=self.logfile,
144148
startparams=self.startparams,
145149
postgres_options=f" {self.postgres_options}" if self.postgres_options else "",
@@ -243,8 +247,11 @@ def running(self) -> bool:
243247
"""Check if server is running."""
244248
if not os.path.exists(self.datadir):
245249
return False
246-
status_code = subprocess.getstatusoutput(f'"{self.executable}" status -D "{self.datadir}"')[0]
247-
return status_code == 0
250+
result = subprocess.run(
251+
[self.executable, "status", "-D", self.datadir],
252+
check=False,
253+
)
254+
return result.returncode == 0
248255

249256
def _windows_terminate_process(self, _sig: Optional[int] = None) -> None:
250257
"""Terminate process on Windows.

tests/test_windows_compatibility.py

Lines changed: 97 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,50 @@ def test_paths_with_unicode_characters(self) -> None:
436436
assert "unix_socket_directories='/tmp/sóckét_dïr_日本語'" in command
437437
assert '-l "/tmp/lög_文件.log"' in command
438438

439+
def test_unixsocketdir_with_apostrophe_is_escaped(self) -> None:
440+
"""Regression test: apostrophes in unixsocketdir are escaped for the GUC parser.
441+
442+
PostgreSQL single-quoted GUC strings use doubled single-quotes as the
443+
escape sequence (SQL-style), so /tmp/o'hare must become
444+
unix_socket_directories='/tmp/o''hare'. An unescaped apostrophe would
445+
prematurely close the GUC string, making PostgreSQL reject the option or
446+
silently misparse it.
447+
"""
448+
with patch("pytest_postgresql.executor.platform.system", return_value="Linux"):
449+
executor = PostgreSQLExecutor(
450+
executable="/usr/lib/postgresql/16/bin/pg_ctl",
451+
host="localhost",
452+
port=5432,
453+
datadir="/tmp/data",
454+
unixsocketdir="/tmp/o'hare",
455+
logfile="/tmp/log",
456+
startparams="-w",
457+
dbname="test",
458+
)
459+
460+
command = executor.command
461+
# Apostrophe must be doubled so the GUC parser sees a valid string
462+
assert "unix_socket_directories='/tmp/o''hare'" in command
463+
# Raw un-escaped form must NOT appear
464+
assert "unix_socket_directories='/tmp/o'hare'" not in command
465+
466+
def test_unixsocketdir_with_multiple_apostrophes_are_escaped(self) -> None:
467+
"""Regression test: multiple apostrophes in unixsocketdir are all escaped."""
468+
with patch("pytest_postgresql.executor.platform.system", return_value="Linux"):
469+
executor = PostgreSQLExecutor(
470+
executable="/usr/lib/postgresql/16/bin/pg_ctl",
471+
host="localhost",
472+
port=5432,
473+
datadir="/tmp/data",
474+
unixsocketdir="/tmp/it's o'hare",
475+
logfile="/tmp/log",
476+
startparams="-w",
477+
dbname="test",
478+
)
479+
480+
command = executor.command
481+
assert "unix_socket_directories='/tmp/it''s o''hare'" in command
482+
439483
def test_command_with_all_special_characters_combined(self) -> None:
440484
"""Test command with multiple types of special characters.
441485
@@ -736,16 +780,15 @@ def test_windows_mixed_slashes(self) -> None:
736780
assert '-l "C:\\temp\\log.txt"' in command
737781

738782

739-
class TestRunningMethodQuoting:
740-
"""Test that the running() method properly quotes the executable path."""
783+
class TestRunningMethod:
784+
"""Test that running() uses safe list-form subprocess invocation."""
741785

742-
def test_running_quotes_executable_with_spaces(self) -> None:
743-
"""Test that running() quotes the executable in the status command.
786+
def test_running_passes_executable_as_argv_element(self) -> None:
787+
"""Test that running() passes the executable as an argv element, not a shell string.
744788
745-
subprocess.getstatusoutput() uses shell=True internally. On Windows,
746-
cmd.exe parses the command string and an unquoted path like
747-
C:\\Program Files\\...\\pg_ctl.exe would be split at the space,
748-
causing the status check to fail silently or error.
789+
Using subprocess.run with a list means paths with spaces (e.g.
790+
C:\\Program Files\\...\\pg_ctl.exe) are passed as a single token to the
791+
OS without any shell parsing, so no quoting is required or desired.
749792
"""
750793
executor = PostgreSQLExecutor(
751794
executable="C:/Program Files/PostgreSQL/17/bin/pg_ctl.exe",
@@ -760,22 +803,22 @@ def test_running_quotes_executable_with_spaces(self) -> None:
760803

761804
with (
762805
patch("pytest_postgresql.executor.os.path.exists", return_value=True),
763-
patch("pytest_postgresql.executor.subprocess.getstatusoutput") as mock_getstatusoutput,
806+
patch("pytest_postgresql.executor.subprocess.run") as mock_run,
764807
):
765-
mock_getstatusoutput.return_value = (0, "")
808+
mock_run.return_value = MagicMock(returncode=0)
766809
executor.running()
767810

768-
# The executable must be double-quoted in the shell command string
769-
called_cmd = mock_getstatusoutput.call_args[0][0]
770-
assert called_cmd.startswith('"C:/Program Files/PostgreSQL/17/bin/pg_ctl.exe"'), (
771-
f"Executable not quoted in status command: {called_cmd!r}"
772-
)
811+
args = mock_run.call_args[0][0]
812+
assert args[0] == "C:/Program Files/PostgreSQL/17/bin/pg_ctl.exe"
813+
assert args[1] == "status"
814+
assert args[2] == "-D"
815+
assert args[3] == "C:/temp/data"
773816

774-
def test_running_quotes_executable_without_spaces(self) -> None:
775-
"""Test that running() still quotes executables without spaces.
817+
def test_running_no_shell_true(self) -> None:
818+
"""Test that running() does not use shell=True.
776819
777-
Even when the executable path has no spaces, it should still be
778-
double-quoted for consistency and correctness.
820+
Passing shell=True with a list is both redundant and risky; the list
821+
form with shell=False (the default) is the correct approach.
779822
"""
780823
executor = PostgreSQLExecutor(
781824
executable="/usr/lib/postgresql/17/bin/pg_ctl",
@@ -790,18 +833,39 @@ def test_running_quotes_executable_without_spaces(self) -> None:
790833

791834
with (
792835
patch("pytest_postgresql.executor.os.path.exists", return_value=True),
793-
patch("pytest_postgresql.executor.subprocess.getstatusoutput") as mock_getstatusoutput,
836+
patch("pytest_postgresql.executor.subprocess.run") as mock_run,
794837
):
795-
mock_getstatusoutput.return_value = (0, "")
838+
mock_run.return_value = MagicMock(returncode=0)
796839
executor.running()
797840

798-
called_cmd = mock_getstatusoutput.call_args[0][0]
799-
assert called_cmd.startswith('"/usr/lib/postgresql/17/bin/pg_ctl"'), (
800-
f"Executable not quoted in status command: {called_cmd!r}"
801-
)
841+
kwargs = mock_run.call_args[1]
842+
assert kwargs.get("shell", False) is False, "running() must not use shell=True"
802843

803-
def test_running_quotes_datadir_with_spaces(self) -> None:
804-
"""Test that running() quotes the datadir in the status command."""
844+
def test_running_returns_true_on_zero_returncode(self) -> None:
845+
"""Test that running() returns True when pg_ctl status exits 0."""
846+
executor = PostgreSQLExecutor(
847+
executable="/usr/lib/postgresql/17/bin/pg_ctl",
848+
host="localhost",
849+
port=5432,
850+
datadir="/tmp/data",
851+
unixsocketdir="/tmp/socket",
852+
logfile="/tmp/log",
853+
startparams="-w",
854+
dbname="test",
855+
)
856+
857+
with (
858+
patch("pytest_postgresql.executor.os.path.exists", return_value=True),
859+
patch("pytest_postgresql.executor.subprocess.run") as mock_run,
860+
):
861+
mock_run.return_value = MagicMock(returncode=0)
862+
assert executor.running() is True
863+
864+
mock_run.return_value = MagicMock(returncode=3)
865+
assert executor.running() is False
866+
867+
def test_running_datadir_with_spaces_passed_as_argv_element(self) -> None:
868+
"""Test that a datadir with spaces is passed verbatim as an argv element."""
805869
executor = PostgreSQLExecutor(
806870
executable="/usr/lib/postgresql/17/bin/pg_ctl",
807871
host="localhost",
@@ -815,10 +879,12 @@ def test_running_quotes_datadir_with_spaces(self) -> None:
815879

816880
with (
817881
patch("pytest_postgresql.executor.os.path.exists", return_value=True),
818-
patch("pytest_postgresql.executor.subprocess.getstatusoutput") as mock_getstatusoutput,
882+
patch("pytest_postgresql.executor.subprocess.run") as mock_run,
819883
):
820-
mock_getstatusoutput.return_value = (0, "")
884+
mock_run.return_value = MagicMock(returncode=0)
821885
executor.running()
822886

823-
called_cmd = mock_getstatusoutput.call_args[0][0]
824-
assert '-D "/tmp/my data dir"' in called_cmd, f"Datadir not quoted in status command: {called_cmd!r}"
887+
args = mock_run.call_args[0][0]
888+
assert args[3] == "/tmp/my data dir", (
889+
f"Datadir not passed as argv element: {args!r}"
890+
)

0 commit comments

Comments
 (0)