Skip to content

Commit e8ed655

Browse files
committed
Refactor command quoting in PostgreSQLExecutor for consistency
- Updated the command templates in `PostgreSQLExecutor` to ensure the executable path is always double-quoted, improving compatibility with paths containing spaces. - Modified tests in `test_windows_compatibility.py` to reflect the changes in command formatting, ensuring that single-quoted PostgreSQL options are preserved correctly within double-quoted command strings. - Added new tests to verify that the `running()` method properly quotes the executable and datadir, enhancing robustness against shell parsing issues.
1 parent f5cc98c commit e8ed655

2 files changed

Lines changed: 114 additions & 16 deletions

File tree

pytest_postgresql/executor.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ class PostgreSQLExecutor(TCPExecutor):
6161
# On Unix, mirakuru uses shlex.split() with shell=False, so single quotes
6262
# inside double-quoted strings are preserved and passed to PostgreSQL's config parser.
6363
UNIX_PROC_START_COMMAND = (
64-
'{executable} start -D "{datadir}" '
64+
'"{executable}" start -D "{datadir}" '
6565
"-o \"-F -p {port} -c log_destination='stderr' "
6666
"-c logging_collector=off "
6767
"-c unix_socket_directories='{unixsocketdir}'{postgres_options}\" "
@@ -73,7 +73,7 @@ class PostgreSQLExecutor(TCPExecutor):
7373
# ignores it on Windows. On Windows, mirakuru forces shell=True so the command
7474
# goes through cmd.exe.
7575
WINDOWS_PROC_START_COMMAND = (
76-
'{executable} start -D "{datadir}" '
76+
'"{executable}" start -D "{datadir}" '
7777
'-o "-F -p {port} -c log_destination=stderr '
7878
'-c logging_collector=off{postgres_options}" '
7979
'-l "{logfile}" {startparams}'
@@ -243,7 +243,7 @@ def running(self) -> bool:
243243
"""Check if server is running."""
244244
if not os.path.exists(self.datadir):
245245
return False
246-
status_code = subprocess.getstatusoutput(f'{self.executable} status -D "{self.datadir}"')[0]
246+
status_code = subprocess.getstatusoutput(f'"{self.executable}" status -D "{self.datadir}"')[0]
247247
return status_code == 0
248248

249249
def _windows_terminate_process(self, _sig: Optional[int] = None) -> None:

tests/test_windows_compatibility.py

Lines changed: 111 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -209,11 +209,14 @@ def test_postgres_options_with_single_quotes_windows(self) -> None:
209209
# postgres_options should be included with single quotes preserved
210210
assert "-c shared_buffers='128MB' -c work_mem='64MB'" in command
211211

212-
def test_postgres_options_with_double_quotes(self) -> None:
213-
"""Test postgres_options containing double quotes.
214-
215-
Double quotes in postgres_options need careful handling as they interact
216-
with the shell's quote parsing.
212+
def test_postgres_options_with_single_quoted_search_path(self) -> None:
213+
"""Test postgres_options with a single-quoted search_path value.
214+
215+
PostgreSQL GUC values are quoted with single quotes in the config syntax.
216+
The Unix template wraps the whole -o argument in double quotes, so
217+
inner double quotes would prematurely terminate that token and produce an
218+
invalid shell command. Single-quoted values are the correct form and must
219+
be preserved verbatim inside the -o argument.
217220
"""
218221
with patch("pytest_postgresql.executor.platform.system", return_value="Linux"):
219222
executor = PostgreSQLExecutor(
@@ -225,12 +228,17 @@ def test_postgres_options_with_double_quotes(self) -> None:
225228
logfile="/tmp/log",
226229
startparams="-w",
227230
dbname="test",
228-
postgres_options='-c search_path="public,other"',
231+
postgres_options="-c search_path='public,other'",
229232
)
230233

231234
command = executor.command
232-
# postgres_options with double quotes should be preserved
233-
assert '-c search_path="public,other"' in command
235+
# The single-quoted search_path value must appear verbatim inside the -o argument
236+
assert "-c search_path='public,other'" in command
237+
# Verify it is embedded inside the -o "..." token (no premature " termination)
238+
o_start = command.index('-o "')
239+
o_end = command.index('"', o_start + 4)
240+
o_argument = command[o_start : o_end + 1]
241+
assert "-c search_path='public,other'" in o_argument
234242

235243
def test_postgres_options_with_paths_containing_spaces(self) -> None:
236244
"""Test postgres_options with file paths containing spaces.
@@ -276,7 +284,7 @@ def test_empty_postgres_options(self) -> None:
276284

277285
command = executor.command
278286
# Command should still be valid with empty postgres_options
279-
assert "/usr/lib/postgresql/16/bin/pg_ctl start" in command
287+
assert '"/usr/lib/postgresql/16/bin/pg_ctl" start' in command
280288
assert '-D "/tmp/data"' in command
281289
assert "unix_socket_directories='/tmp/socket'" in command
282290
# Should not have trailing space before closing quote in -o parameter
@@ -306,7 +314,7 @@ def test_empty_startparams(self) -> None:
306314

307315
command = executor.command
308316
# Command should be valid with empty startparams
309-
assert "/usr/lib/postgresql/16/bin/pg_ctl start" in command
317+
assert '"/usr/lib/postgresql/16/bin/pg_ctl" start' in command
310318
assert '-l "/tmp/log"' in command
311319
# Command should not have trailing spaces at the end
312320
assert not command.endswith(" ")
@@ -332,7 +340,7 @@ def test_both_empty_postgres_options_and_startparams(self) -> None:
332340

333341
command = executor.command
334342
# Command should be valid with both empty
335-
assert "C:/Program Files/PostgreSQL/bin/pg_ctl.exe start" in command
343+
assert '"C:/Program Files/PostgreSQL/bin/pg_ctl.exe" start' in command
336344
assert '-D "C:/temp/data"' in command
337345
assert '-l "C:/temp/log"' in command
338346
# Windows template should not have unix_socket_directories
@@ -621,7 +629,7 @@ def test_command_formatting_windows(self) -> None:
621629
# The command should be properly formatted without single quotes
622630
# and without unix_socket_directories (irrelevant on Windows)
623631
expected_parts = [
624-
"C:/Program Files/PostgreSQL/bin/pg_ctl.exe start",
632+
'"C:/Program Files/PostgreSQL/bin/pg_ctl.exe" start',
625633
'-D "C:/temp/data"',
626634
'-o "-F -p 5555 -c log_destination=stderr',
627635
"-c logging_collector=off",
@@ -723,6 +731,96 @@ def test_windows_mixed_slashes(self) -> None:
723731

724732
command = executor.command
725733
# Paths with backslashes should be properly quoted
726-
assert "C:\\Program Files\\PostgreSQL\\bin\\pg_ctl.exe start" in command
734+
assert '"C:\\Program Files\\PostgreSQL\\bin\\pg_ctl.exe" start' in command
727735
assert '-D "C:\\temp\\data"' in command
728736
assert '-l "C:\\temp\\log.txt"' in command
737+
738+
739+
class TestRunningMethodQuoting:
740+
"""Test that the running() method properly quotes the executable path."""
741+
742+
def test_running_quotes_executable_with_spaces(self) -> None:
743+
"""Test that running() quotes the executable in the status command.
744+
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.
749+
"""
750+
executor = PostgreSQLExecutor(
751+
executable="C:/Program Files/PostgreSQL/17/bin/pg_ctl.exe",
752+
host="localhost",
753+
port=5432,
754+
datadir="C:/temp/data",
755+
unixsocketdir="C:/temp/socket",
756+
logfile="C:/temp/log",
757+
startparams="-w",
758+
dbname="test",
759+
)
760+
761+
with (
762+
patch("pytest_postgresql.executor.os.path.exists", return_value=True),
763+
patch("pytest_postgresql.executor.subprocess.getstatusoutput") as mock_getstatusoutput,
764+
):
765+
mock_getstatusoutput.return_value = (0, "")
766+
executor.running()
767+
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+
)
773+
774+
def test_running_quotes_executable_without_spaces(self) -> None:
775+
"""Test that running() still quotes executables without spaces.
776+
777+
Even when the executable path has no spaces, it should still be
778+
double-quoted for consistency and correctness.
779+
"""
780+
executor = PostgreSQLExecutor(
781+
executable="/usr/lib/postgresql/17/bin/pg_ctl",
782+
host="localhost",
783+
port=5432,
784+
datadir="/tmp/data",
785+
unixsocketdir="/tmp/socket",
786+
logfile="/tmp/log",
787+
startparams="-w",
788+
dbname="test",
789+
)
790+
791+
with (
792+
patch("pytest_postgresql.executor.os.path.exists", return_value=True),
793+
patch("pytest_postgresql.executor.subprocess.getstatusoutput") as mock_getstatusoutput,
794+
):
795+
mock_getstatusoutput.return_value = (0, "")
796+
executor.running()
797+
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+
)
802+
803+
def test_running_quotes_datadir_with_spaces(self) -> None:
804+
"""Test that running() quotes the datadir in the status command."""
805+
executor = PostgreSQLExecutor(
806+
executable="/usr/lib/postgresql/17/bin/pg_ctl",
807+
host="localhost",
808+
port=5432,
809+
datadir="/tmp/my data dir",
810+
unixsocketdir="/tmp/socket",
811+
logfile="/tmp/log",
812+
startparams="-w",
813+
dbname="test",
814+
)
815+
816+
with (
817+
patch("pytest_postgresql.executor.os.path.exists", return_value=True),
818+
patch("pytest_postgresql.executor.subprocess.getstatusoutput") as mock_getstatusoutput,
819+
):
820+
mock_getstatusoutput.return_value = (0, "")
821+
executor.running()
822+
823+
called_cmd = mock_getstatusoutput.call_args[0][0]
824+
assert '-D "/tmp/my data dir"' in called_cmd, (
825+
f"Datadir not quoted in status command: {called_cmd!r}"
826+
)

0 commit comments

Comments
 (0)