Let’s say you have an array of strings:

args = [ "arg1", "an argument with whitespace", 'even some "quotes"']

..and you want to pass them to a command, exactly as is. You don’t want it split on spaces, you don’t want quotes to disappear. You just want to pass exactly these strings to the command you’re running. In python, you would do something like:

subprocess.check_call(["echo"] + args)

In low-level C, it’s more effort, but it’s not really harder - you just use the execv* family of system calls, which takes an array of strings. At least on a UNIX-like OS.

But what if you’re using C# on Windows? Then it’s going to cost you a veritable screenful of code if you want to not screw it up. And you’ll probably screw it up. The internet has plenty of examples that happen to work well enough for simple data. But then they break when you add spaces, or double quotes, or backslashes, or multiple backslashes followed by a double quote. You don’t want that code. You want this code.

I’m honestly floored that nobody has published this code anywhere before (that I could find). So with the firm belief that it’s insane for anybody to have to implement this ridiculous escaping scheme for themselves, here it is:

QuoteArguments.cs:

using System;
using System.Text;
using System.Collections;
 
public class QuoteArguments
{
  public static string Quote(IList args) {
    StringBuilder sb = new StringBuilder();
    foreach (string arg in args) {
      int backslashes = 0;

      // Add a space to separate this argument from the others
      if (sb.Length > 0) {
        sb.Append(" ");
      }

      sb.Append('"');

      foreach (char c in arg) {
        if (c == '\\') {
          // Don't know if we need to double yet.
          backslashes++;
        }
        else if (c == '"') {
          // Double backslashes.
          sb.Append(new String('\\', backslashes*2));
          backslashes = 0;
          sb.Append("\\\"");
        } else {
          // Normal char
          if (backslashes > 0) {
            sb.Append(new String('\\', backslashes));
            backslashes = 0;
          }
          sb.Append(c);
        }
      }

      // Add remaining backslashes, if any.
      if (backslashes > 0) {
        sb.Append(new String('\\', backslashes));
      }

      sb.Append(new String('\\', backslashes));
      sb.Append('"');
    }
    return sb.ToString();
  }
}

I’ve made a github project for it, so there’s a canonical place for it if it ever needs updating: csharp-quote-argv. It’s MIT Licensed, so you can do basically whatever you want with it, as long as you keep the copyright notice intact and don’t use the author’s name to promote your stuff.

You can use it a lot like this:

  Process.Start(
    new ProcessStartInfo(commandName, QuoteArguments.Quote(args))
    { UseShellExecute = false }
  );

Don’t forget the UseShellExecute = false part. If you don’t specify that, you get to double quote everything with different rules (for cmd.exe), at which point you’re likely to lose an eye in frustration.

The algorithm is translated from python’s subprocess module, so it’s already been battle tested pretty thoroughly (big thanks to the original author, Peter Astrand). And just in case I transcribed it wrong, I fuzz tested it for a morning by running a script that throws randomly-generated ASCII strings at it and testing they came out the other end unmolested.

Caveats:

Because having nice things is not generally something you can do on Windows, you should be aware that:

  • Technically speaking, there is no right answer to this problem - programs are free to parse their argument string with any hair-brained scheme they invent. So this code only works for running programs that use the same rules as Microsoft’s C runtime. But that’s probably every program you will ever care about.

  • This isn’t cross-platform at all. mono under linux will (for example) throw an exception for any string that contains a single quote. I believe mono passes the arguments string through the UNIX style g_shell_parse_argv() (from GLib) to break up the arguments back into an array. That seems like it’ll just kill any attempts at using this API cross-platform, but maybe it’s intentional. If you want to find out, you can follow this bug I filed.