Repository: microsoft/ptvsd

Issue:
subprocess arguments incorrectly escaped inside the debugger
@Meinersbur commented on [Sun Mar 10 2019](https://github.com/Microsoft/PTVS/issues/5155)

Consider the following program:
```
import sys
import subprocess

python = sys.executable
subprocess.check_call([python, '-c', 'print("A B")'])
subprocess.check_call([python, '-c', 'print("XY")'])
print("Done")
```

When executed with python from the command line this prints:
```
>python3 module1.py
A B
XY
Done

>python3 --version
Python 3.7.2
```

When executed from inside the Visual Studio 2017 debugger (same python 3.7.2, Python installed product 15.9.18254.1 according to Help -> About...), and Visual Studio 2019 preview, I get:
```
A B
Traceback (most recent call last):
  File "<string>", line 1, in <module>
NameError: name 'XY' is not defined
```

The issue seems that the command line argument is only escaped when there is a space in it, but not deemed necessary with quotes. I checked the subprocess module, it correctly calls CreateProcess with escaped arguments in both cases. That is, the python debugger seems to hook the CreateProcess call, parses the command line, but re-escapes it insufficiently.




Discussion:
There are several other issues with the quoting code - it also doesn't handle terminal `\` well, for example, and it doesn't properly escape literal backslashes that precede quotes. 

Resolving patch:
diff --git a/src/ptvsd/_vendored/pydevd/_pydev_bundle/pydev_monkey.py b/src/ptvsd/_vendored/pydevd/_pydev_bundle/pydev_monkey.py
--- a/src/ptvsd/_vendored/pydevd/_pydev_bundle/pydev_monkey.py
+++ b/src/ptvsd/_vendored/pydevd/_pydev_bundle/pydev_monkey.py
@@ -1,5 +1,6 @@
 # License: EPL
 import os
+import re
 import sys
 import traceback
 from _pydev_imps._pydev_saved_modules import threading
@@ -87,19 +88,40 @@ def remove_quotes_from_args(args):
         return args
 
 
+def quote_arg_win32(arg):
+    # See if we need to quote at all - empty strings need quoting, as do strings
+    # with whitespace or quotes in them. Backslashes do not need quoting.
+    if arg and not set(arg).intersection(' "\t\n\v'):
+        return arg
+
+    # Per https://docs.microsoft.com/en-us/windows/desktop/api/shellapi/nf-shellapi-commandlinetoargvw,
+    # the standard way to interpret arguments in double quotes is as follows:
+    #
+    #       2N backslashes followed by a quotation mark produce N backslashes followed by
+    #       begin/end quote. This does not become part of the parsed argument, but toggles
+    #       the "in quotes" mode.
+    #
+    #       2N+1 backslashes followed by a quotation mark again produce N backslashes followed
+    #       by a quotation mark literal ("). This does not toggle the "in quotes" mode.
+    #
+    #       N backslashes not followed by a quotation mark simply produce N backslashes.
+    #
+    # This code needs to do the reverse transformation, thus:
+    #
+    #       N backslashes followed by " produce 2N+1 backslashes followed by "
+    #
+    #       N backslashes at the end (i.e. where the closing " goes) produce 2N backslashes.
+    #
+    #       N backslashes in any other position remain as is.
+
+    arg = re.sub(r'(\\*)\"', r'\1\1\\"', arg)
+    arg = re.sub(r'(\\*)$', r'\1\1', arg)
+    return '"' + arg + '"'
+
+
 def quote_args(args):
     if sys.platform == "win32":
-        quoted_args = []
-        for x in args:
-            if x.startswith('"') and x.endswith('"'):
-                quoted_args.append(x)
-            else:
-                if ' ' in x:
-                    x = x.replace('"', '\\"')
-                    quoted_args.append('"%s"' % x)
-                else:
-                    quoted_args.append(x)
-        return quoted_args
+        return list(map(quote_arg_win32, args))
     else:
         return args
 
@@ -208,7 +230,11 @@ def patch_args(args):
 
 
 def str_to_args_windows(args):
-    # see http:#msdn.microsoft.com/en-us/library/a1y7w461.aspx
+    # See https://docs.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments.
+    #
+    # Implemetation ported from DebugPlugin.parseArgumentsWindows:
+    # https://github.com/eclipse/eclipse.platform.debug/blob/master/org.eclipse.debug.core/core/org/eclipse/debug/core/DebugPlugin.java
+
     result = []
 
     DEFAULT = 0
@@ -272,11 +298,6 @@ def str_to_args_windows(args):
                     # a single double quote.
                     buf += '"'
                     i += 1
-                elif len(buf) == 0:
-                    # empty string on Windows platform. Account for bug in constructor of
-                    # JDK's java.lang.ProcessImpl.
-                    result.append("\"\"")
-                    state = DEFAULT
                 else:
                     state = ARG
             else:
@@ -317,7 +338,7 @@ def warn_multiproc():
     # log_error_once(
     #     "pydev debugger: New process is launching (breakpoints won't work in the new process).\n"
     #     "pydev debugger: To debug that process please enable 'Attach to subprocess automatically while debugging?' option in the debugger settings.\n")
-    # 
+    #
 
 def create_warn_multiproc(original_name):
 
@@ -615,7 +636,7 @@ def __call__(self):
                 thread_id = get_current_thread_id(t)
                 global_debugger.notify_thread_created(thread_id, t)
                 _on_set_trace_for_new_thread(global_debugger)
-            
+
             if getattr(global_debugger, 'thread_analyser', None) is not None:
                 try:
                     from pydevd_concurrency_analyser.pydevd_concurrency_logger import log_new_thread
@@ -627,7 +648,7 @@ def __call__(self):
         finally:
             if thread_id is not None:
                 global_debugger.notify_thread_not_alive(thread_id)
-        
+
         return ret
 
 
diff --git a/src/ptvsd/log.py b/src/ptvsd/log.py
--- a/src/ptvsd/log.py
+++ b/src/ptvsd/log.py
@@ -109,10 +109,8 @@ def g(*args, **kwargs):
         try:
             return f(*args, **kwargs)
         except:
-            try:
-                name = f.__qualname__
-            except AttributeError:
-                name = f.__name__
+            # Must not use try/except here to avoid overwriting the caught exception.
+            name = f.__qualname__ if hasattr(f, '__qualname__') else f.__name__
             exception('Exception escaped from {0}', name)
             raise
     return g


Test patch:
diff --git a/tests/func/test_multiproc.py b/tests/func/test_multiproc.py
--- a/tests/func/test_multiproc.py
+++ b/tests/func/test_multiproc.py
@@ -226,3 +226,64 @@ def parent():
 
             child_session.wait_for_termination()
             parent_session.wait_for_exit()
+
+
+@pytest.mark.skipif(sys.version_info < (3, 0) and (platform.system() != 'Windows'),
+                    reason='Bug #935')
+def test_argv_quoting(pyfile, run_as, start_method):
+    @pyfile
+    def args():
+        # import_and_enable_debugger
+        args = [ # noqa
+            r'regular',
+            r'',
+            r'with spaces'
+            r'"quoted"',
+            r'" quote at start',
+            r'quote at end "',
+            r'quote in " the middle',
+            r'quotes "in the" middle',
+            r'\path with\spaces',
+            r'\path\with\terminal\backslash' + '\\',
+            r'backslash \" before quote',
+        ]
+
+    @pyfile
+    def parent():
+        from dbgimporter import import_and_enable_debugger
+        import_and_enable_debugger()
+
+        import sys
+        import subprocess
+        from args import args
+        child = sys.argv[1]
+        subprocess.check_call([sys.executable] + [child] + args)
+
+    @pyfile
+    def child():
+        # import_and_enable_debugger
+        import backchannel
+        import sys
+
+        from args import args as expected_args
+        backchannel.write_json(expected_args)
+
+        actual_args = sys.argv[1:]
+        backchannel.write_json(actual_args)
+
+    with DebugSession() as session:
+        session.initialize(
+            target=(run_as, parent),
+            start_method=start_method,
+            program_args=[child],
+            use_backchannel=True,
+            ignore_unobserved=[Event('continued')],
+        )
+
+        session.start_debugging()
+
+        expected_args = session.read_json()
+        actual_args = session.read_json()
+        assert expected_args == actual_args
+
+        session.wait_for_exit()