@@ -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