Skip to content

Commit b4343e9

Browse files
committed
Make command line escaping on Windows not add superfluous double quotes
1 parent 98101f8 commit b4343e9

File tree

2 files changed

+69
-24
lines changed

2 files changed

+69
-24
lines changed

System/Process/Windows.hsc

Lines changed: 66 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -422,18 +422,21 @@ commandToProcess
422422
-> IO (FilePath, String)
423423
commandToProcess (ShellCommand string) = do
424424
cmd <- findCommandInterpreter
425-
return (cmd, translateInternal cmd ++ " /c " ++ string)
426-
-- We don't want to put the cmd into a single
427-
-- argument, because cmd.exe will not try to split it up. Instead,
428-
-- we just tack the command on the end of the cmd.exe command line,
429-
-- which partly works. There seem to be some quoting issues, but
430-
-- I don't have the energy to find+fix them right now (ToDo). --SDM
431-
-- (later) Now I don't know what the above comment means. sigh.
425+
-- Note: this is a way to pass a command directly to cmd.exe. Callers are
426+
-- responsible for properly quoting and sanitizing this string.
427+
return (cmd, translateInternal0 cmd ++ " /c " ++ string)
432428
commandToProcess (RawCommand cmd args)
433429
| map toLower (takeWinExtension cmd) `elem` [".bat", ".cmd"]
434-
= return (cmd, translateInternal cmd ++ concatMap ((' ':) . translateCmdExeArg) args)
430+
= return (cmd, translateInternal0 cmd ++ concatMap ((' ':) . translateCmdExeArg) args)
435431
| otherwise
436-
= return (cmd, translateInternal cmd ++ concatMap ((' ':) . translateInternal) args)
432+
-- Note: on Windows, commands are passed as a single string of space-delimited
433+
-- pieces, *not* as an executable name + list of args as on POSIX. Windows
434+
-- programs can inspect this command string and parse it however they want.
435+
--
436+
-- However, most programs use the CommandLineToArgvW function from the Win32
437+
-- API to parse their arguments. Here we escape the argument in such a way
438+
-- that they'll always come through correctly with this function.
439+
= return (cmd, translateInternal0 cmd ++ concatMap ((' ':) . translateInternal) args)
437440

438441
-- TODO: filepath should also be updated with 'takeWinExtension'. Perhaps
439442
-- some day we can remove this logic from `process` but there is no hurry.
@@ -490,6 +493,60 @@ findCommandInterpreter = do
490493
"findCommandInterpreter" Nothing Nothing)
491494
Just cmd -> return cmd
492495

496+
-- | Escape the *first* argument for Windows CreateProcess.
497+
-- For subsequent arguments, see 'translateInternal'.
498+
--
499+
-- The first argument is parsed differently than subsequent arguments. It must be a valid
500+
-- Windows path. To ensure it's escaped properly, we do two things:
501+
-- a) Strip out quotes from the path, since quotes are forbidden in Windows paths
502+
-- (see https://stackoverflow.com/a/31976060)
503+
-- b) If the resulting string contains any whitespace, wrap it in double quotes. Otherwise,
504+
-- leave it as-is.
505+
translateInternal0 :: String -> String
506+
translateInternal0 exe
507+
| not (hasWhitespace exe) = exeWithoutForbiddenChars
508+
| otherwise = "\"" ++ exeWithoutForbiddenChars ++ "\""
509+
where
510+
exeWithoutForbiddenChars = filter (not . (== '"')) exe
511+
512+
hasWhitespace = any (`elem` " \t")
513+
514+
-- | Escape a single argument for Windows CreateProcess.
515+
-- (Not the first argument! For argv[0], see 'translateInternal0'.)
516+
--
517+
-- This follows the escaping rules described in Microsoft's documentation:
518+
-- https://docs.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-commandlinetoargvw
519+
translateInternal :: String -> String
520+
translateInternal arg
521+
| not (needsQuoting arg) = arg
522+
| otherwise = "\"" ++ escape arg True ++ "\""
523+
where
524+
-- Check if an argument needs quoting
525+
needsQuoting :: String -> Bool
526+
needsQuoting s = null s || any (`elem` specialChars) s
527+
528+
specialChars :: [Char]
529+
specialChars = [' ', '\t', '"', '\'', '(', ')', '<', '>', '&', '|', '^', '%']
530+
531+
-- Escape the string, with a flag indicating if we're at the end
532+
-- (meaning the next character would be the closing quote)
533+
escape :: String -> Bool -> String
534+
escape [] _ = []
535+
escape ('"':xs) endsWithQuote = "\\\"" ++ escape xs endsWithQuote
536+
escape xs endsWithQuote =
537+
let (backslashes, rest) = span (== '\\') xs
538+
bsCount = length backslashes
539+
in case rest of
540+
-- If backslashes are followed by a quote, they need to be doubled plus one
541+
'"':rest' -> replicate (2 * bsCount + 1) '\\' ++ "\"" ++ escape rest' endsWithQuote
542+
543+
-- If we're at the end of the string, backslashes need to be doubled
544+
[] | endsWithQuote -> replicate (2 * bsCount) '\\'
545+
546+
-- Otherwise, backslashes remain as is
547+
[] -> replicate bsCount '\\'
548+
(c:cs) -> replicate bsCount '\\' ++ c : escape cs endsWithQuote
549+
493550
-- | Alternative regime used to escape arguments destined for scripts
494551
-- interpreted by @cmd.exe@, (e.g. @.bat@ and @.cmd@ files).
495552
--
@@ -515,18 +572,6 @@ translateCmdExeArg xs = "^\"" ++ snd (foldr escape (True,"^\"") xs)
515572
| c `elem` "^<>|&()" = (False, '^' : c : str)
516573
| otherwise = (False, c : str)
517574

518-
translateInternal :: String -> String
519-
translateInternal xs = '"' : snd (foldr escape (True,"\"") xs)
520-
where escape '"' (_, str) = (True, '\\' : '"' : str)
521-
escape '\\' (True, str) = (True, '\\' : '\\' : str)
522-
escape '\\' (False, str) = (False, '\\' : str)
523-
escape c (_, str) = (False, c : str)
524-
-- See long comment above for what this function is trying to do.
525-
--
526-
-- The Bool passed back along the string is True iff the
527-
-- rest of the string is a sequence of backslashes followed by
528-
-- a double quote.
529-
530575
withCEnvironment :: [(String,String)] -> (Ptr CWString -> IO a) -> IO a
531576
withCEnvironment envir act =
532577
let env' = foldr (\(name, val) env0 -> name ++ ('=':val)++'\0':env0) "\0" envir

test/main.hs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,12 @@ testModifiers = do
9797

9898
testSubdirectories :: IO ()
9999
testSubdirectories = ifWindows $ run "subdirectories" $ do
100-
withCurrentDirectory "exes" $ do
101-
res1 <- readCreateProcess (proc "./echo.bat" []) ""
100+
withCurrentDirectory "../exes" $ do
101+
res1 <- readCreateProcess (proc ".\\echo.bat" []) ""
102102
unless ("parent" `isInfixOf` res1 && not ("child" `isInfixOf` res1)) $ error $
103103
"echo.bat with cwd failed: " ++ show res1
104104

105-
res2 <- readCreateProcess (proc "./echo.bat" []) { cwd = Just "subdir" } ""
105+
res2 <- readCreateProcess (proc ".\\echo.bat" []) { cwd = Just "subdir" } ""
106106
unless ("child" `isInfixOf` res2 && not ("parent" `isInfixOf` res2)) $ error $
107107
"echo.bat with cwd failed: " ++ show res2
108108

0 commit comments

Comments
 (0)