extdiff: avoid unexpected quoting arguments for external tools (issue4463)
authorFUJIWARA Katsunori <foozy@lares.dti.ne.jp>
Thu, 25 Dec 2014 23:33:26 +0900
changeset 23680 4075f2f8ea53
parent 23679 dd1e73c4be13
child 23681 9476cb62298e
extdiff: avoid unexpected quoting arguments for external tools (issue4463) Before this patch, all command line arguments for external tools are quoted by the combination of "shlex.split" and "util.shellquote". But this causes some problems. - some problematic commands can't work correctly with quoted arguments For example, 'WinMerge /r ....' is OK, but 'WinMerge "/r" ....' is NG. See also below for detail about this problem. https://bitbucket.org/tortoisehg/thg/issue/3978/ - quoting itself may change semantics of arguments For example, when the environment variable CONCAT="foo bar baz': - mydiff $CONCAT => mydiff foo bar baz (taking 3 arguments) - mydiff "$CONCAT" => mydiff "foo bar baz" (taking only 1 argument) For another example, single quoting (= "util.shellquote") on POSIX environment prevents shells from expanding environment variables, tilde, and so on: - mydiff "$HOME" => mydiff /home/foobar - mydiff '$HOME' => mydiff $HOME - "shlex.split" can't handle some special characters correctly It just splits specified command line by whitespaces. For example, "echo foo;echo bar" is split into ["echo", "foo;echo", "bar"]. On the other hand, if quoting itself is omitted, users can't specify options including space characters with "--option" at runtime. The root cause of this issue is that "shlex.split + util.shellquote" combination loses whether users really want to quote each command line elements or not, even though these can be quoted arbitrarily in configurations. To resolve this problem, this patch does: - prevent configurations from being processed by "shlex.split" and "util.shellquote" only (possibly) "findexe"-ed or "findexternaltool"-ed command path is "util.shellquote", because it may contain whitespaces. - quote options specified by "--option" via command line at runtime This patch also makes "dodiff()" take only one "args" argument instead of "diffcmd" and "diffopts. It also omits applying "util.shellquote" on "args", because "args" should be already stringified in "extdiff()" and "mydiff()". The last hunk for "test-extdiff.t" replaces two whitespaces by single whitespace, because change of "' '.join()" logic causes omitting redundant whitespaces.
hgext/extdiff.py
tests/test-extdiff.t
--- a/hgext/extdiff.py	Sun Dec 28 23:59:57 2014 +0100
+++ b/hgext/extdiff.py	Thu Dec 25 23:33:26 2014 +0900
@@ -109,7 +109,7 @@
                                   os.lstat(dest).st_mtime))
     return dirname, fns_and_mtime
 
-def dodiff(ui, repo, diffcmd, diffopts, pats, opts):
+def dodiff(ui, repo, args, pats, opts):
     '''Do the actual diff:
 
     - copy to a temp structure if diffing 2 internal revisions
@@ -120,7 +120,6 @@
 
     revs = opts.get('rev')
     change = opts.get('change')
-    args = ' '.join(map(util.shellquote, diffopts))
     do3way = '$parent2' in args
 
     if revs and change:
@@ -222,8 +221,7 @@
         regex = '\$(parent2|parent1?|child|plabel1|plabel2|clabel|root)'
         if not do3way and not re.search(regex, args):
             args += ' $parent1 $child'
-        args = re.sub(regex, quote, args)
-        cmdline = util.shellquote(diffcmd) + ' ' + args
+        cmdline = re.sub(regex, quote, args)
 
         ui.debug('running %r in %s\n' % (cmdline, tmproot))
         ui.system(cmdline, cwd=tmproot)
@@ -271,7 +269,8 @@
     if not program:
         program = 'diff'
         option = option or ['-Npru']
-    return dodiff(ui, repo, program, option, pats, opts)
+    cmdline = ' '.join(map(util.shellquote, [program] + option))
+    return dodiff(ui, repo, cmdline, pats, opts)
 
 def uisetup(ui):
     for cmd, path in ui.configitems('extdiff'):
@@ -281,29 +280,37 @@
                 path = util.findexe(cmd)
                 if path is None:
                     path = filemerge.findexternaltool(ui, cmd) or cmd
-            diffopts = shlex.split(ui.config('extdiff', 'opts.' + cmd, ''))
+            diffopts = ui.config('extdiff', 'opts.' + cmd, '')
+            cmdline = util.shellquote(path)
+            if diffopts:
+                cmdline += ' ' + diffopts
         elif cmd.startswith('opts.'):
             continue
         else:
-            # command = path opts
             if path:
-                diffopts = shlex.split(path)
-                path = diffopts.pop(0)
+                # case "cmd = path opts"
+                cmdline = path
+                diffopts = len(shlex.split(cmdline)) > 1
             else:
-                path, diffopts = util.findexe(cmd), []
+                # case "cmd ="
+                path = util.findexe(cmd)
                 if path is None:
                     path = filemerge.findexternaltool(ui, cmd) or cmd
+                cmdline = util.shellquote(path)
+                diffopts = False
         # look for diff arguments in [diff-tools] then [merge-tools]
-        if diffopts == []:
+        if not diffopts:
             args = ui.config('diff-tools', cmd+'.diffargs') or \
                    ui.config('merge-tools', cmd+'.diffargs')
             if args:
-                diffopts = shlex.split(args)
-        def save(cmd, path, diffopts):
+                cmdline += ' ' + args
+        def save(cmdline):
             '''use closure to save diff command to use'''
             def mydiff(ui, repo, *pats, **opts):
-                return dodiff(ui, repo, path, diffopts + opts['option'],
-                              pats, opts)
+                options = ' '.join(map(util.shellquote, opts['option']))
+                if options:
+                    options = ' ' + options
+                return dodiff(ui, repo, cmdline + options, pats, opts)
             doc = _('''\
 use %(path)s to diff repository (or selected files)
 
@@ -325,6 +332,6 @@
             # right encoding) prevents that.
             mydiff.__doc__ = doc.decode(encoding.encoding)
             return mydiff
-        cmdtable[cmd] = (save(cmd, path, diffopts),
+        cmdtable[cmd] = (save(cmdline),
                          cmdtable['extdiff'][1][1:],
                          _('hg %s [OPTION]... [FILE]...') % cmd)
--- a/tests/test-extdiff.t	Sun Dec 28 23:59:57 2014 +0100
+++ b/tests/test-extdiff.t	Thu Dec 25 23:33:26 2014 +0900
@@ -94,6 +94,72 @@
   diffing */extdiff.*/a.2a13a4d2da36/a a.46c0e4daeb72/a (glob)
   diff-like tools yield a non-zero exit code
 
+issue4463: usage of command line configuration without additional quoting
+
+  $ cat <<EOF >> $HGRCPATH
+  > [extdiff]
+  > cmd.4463a = echo
+  > opts.4463a = a-naked 'single quoted' "double quoted"
+  > 4463b = echo b-naked 'single quoted' "double quoted"
+  > echo =
+  > EOF
+  $ hg update -q -C 0
+  $ echo a >> a
+#if windows
+  $ hg --debug 4463a | grep '^running'
+  running '"echo" a-naked \'single quoted\' "double quoted" "*\\a" "*\\a"' in */extdiff.* (glob)
+  $ hg --debug 4463b | grep '^running'
+  running 'echo b-naked \'single quoted\' "double quoted" "*\\a" "*\\a"' in */extdiff.* (glob)
+  $ hg --debug echo | grep '^running'
+  running '"*echo*" "*\\a" "*\\a"' in */extdiff.* (glob)
+#else
+  $ hg --debug 4463a | grep '^running'
+  running '\'echo\' a-naked \'single quoted\' "double quoted" \'*/a\' \'$TESTTMP/a/a\'' in */extdiff.* (glob)
+  $ hg --debug 4463b | grep '^running'
+  running 'echo b-naked \'single quoted\' "double quoted" \'*/a\' \'$TESTTMP/a/a\'' in */extdiff.* (glob)
+  $ hg --debug echo | grep '^running'
+  running "'*echo*' '*/a' '$TESTTMP/a/a'" in */extdiff.* (glob)
+#endif
+
+(getting options from other than extdiff section)
+
+  $ cat <<EOF >> $HGRCPATH
+  > [extdiff]
+  > # using diff-tools diffargs
+  > 4463b2 = echo
+  > # using merge-tools diffargs
+  > 4463b3 = echo
+  > # no diffargs
+  > 4463b4 = echo
+  > [diff-tools]
+  > 4463b2.diffargs = b2-naked 'single quoted' "double quoted"
+  > [merge-tools]
+  > 4463b3.diffargs = b3-naked 'single quoted' "double quoted"
+  > EOF
+#if windows
+  $ hg --debug 4463b2 | grep '^running'
+  running 'echo b2-naked \'single quoted\' "double quoted" "*\\a" "*\\a"' in */extdiff.* (glob)
+  $ hg --debug 4463b3 | grep '^running'
+  running 'echo b3-naked \'single quoted\' "double quoted" "*\\a" "*\\a"' in */extdiff.* (glob)
+  $ hg --debug 4463b4 | grep '^running'
+  running 'echo "*\\a" "*\\a"' in */extdiff.* (glob)
+  $ hg --debug 4463b4 --option 'being quoted' | grep '^running'
+  running 'echo "being quoted" "*\\a" "*\\a"' in */extdiff.* (glob)
+  $ hg --debug extdiff -p echo --option 'being quoted' | grep '^running'
+  running '"echo" "being quoted" "*\\a" "*\\a"' in */extdiff.* (glob)
+#else
+  $ hg --debug 4463b2 | grep '^running'
+  running 'echo b2-naked \'single quoted\' "double quoted" \'*/a\' \'$TESTTMP/a/a\'' in */extdiff.* (glob)
+  $ hg --debug 4463b3 | grep '^running'
+  running 'echo b3-naked \'single quoted\' "double quoted" \'*/a\' \'$TESTTMP/a/a\'' in */extdiff.* (glob)
+  $ hg --debug 4463b4 | grep '^running'
+  running "echo '*/a' '$TESTTMP/a/a'" in */extdiff.* (glob)
+  $ hg --debug 4463b4 --option 'being quoted' | grep '^running'
+  running "echo 'being quoted' '*/a' '$TESTTMP/a/a'" in */extdiff.* (glob)
+  $ hg --debug extdiff -p echo --option 'being quoted' | grep '^running'
+  running "'echo' 'being quoted' '*/a' '$TESTTMP/a/a'" in */extdiff.* (glob)
+#endif
+
 #if execbit
 
 Test extdiff of multiple files in tmp dir:
@@ -207,7 +273,7 @@
   making snapshot of 2 files from working directory
     a
     b
-  running "'$TESTTMP/a/dir/tool.sh'  'a.*' 'a'" in */extdiff.* (glob)
+  running "'$TESTTMP/a/dir/tool.sh' 'a.*' 'a'" in */extdiff.* (glob)
   ** custom diff **
   cleaning up temp directory
   [1]