@@ -422,18 +422,21 @@ commandToProcess
422
422
-> IO (FilePath , String )
423
423
commandToProcess (ShellCommand string) = do
424
424
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)
432
428
commandToProcess (RawCommand cmd args)
433
429
| map toLower (takeWinExtension cmd) `elem` [" .bat" , " .cmd" ]
434
- = return (cmd, translateInternal cmd ++ concatMap ((' ' : ) . translateCmdExeArg) args)
430
+ = return (cmd, translateInternal0 cmd ++ concatMap ((' ' : ) . translateCmdExeArg) args)
435
431
| 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)
437
440
438
441
-- TODO: filepath should also be updated with 'takeWinExtension'. Perhaps
439
442
-- some day we can remove this logic from `process` but there is no hurry.
@@ -490,6 +493,60 @@ findCommandInterpreter = do
490
493
" findCommandInterpreter" Nothing Nothing )
491
494
Just cmd -> return cmd
492
495
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
+
493
550
-- | Alternative regime used to escape arguments destined for scripts
494
551
-- interpreted by @cmd.exe@, (e.g. @.bat@ and @.cmd@ files).
495
552
--
@@ -515,18 +572,6 @@ translateCmdExeArg xs = "^\"" ++ snd (foldr escape (True,"^\"") xs)
515
572
| c `elem` " ^<>|&()" = (False , ' ^' : c : str)
516
573
| otherwise = (False , c : str)
517
574
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
-
530
575
withCEnvironment :: [(String ,String )] -> (Ptr CWString -> IO a ) -> IO a
531
576
withCEnvironment envir act =
532
577
let env' = foldr (\ (name, val) env0 -> name ++ (' =' : val)++ '\ 0 ': env0) " \0" envir
0 commit comments