@@ -422,18 +422,21 @@ commandToProcess
422422 -> IO (FilePath , String )
423423commandToProcess (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)
432428commandToProcess (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-
530575withCEnvironment :: [(String ,String )] -> (Ptr CWString -> IO a ) -> IO a
531576withCEnvironment envir act =
532577 let env' = foldr (\ (name, val) env0 -> name ++ (' =' : val)++ '\ 0 ': env0) " \0" envir
0 commit comments