From 747bd096a98c66da8e4f323f472cfa08838aa036 Mon Sep 17 00:00:00 2001 From: jfacemyer <1776340+jfacemyer@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:39:17 -0400 Subject: [PATCH] AffinityHook: quote forwarded arguments by the CommandLineToArgvW rules #45 wraps an argument in quotes when it contains a space. CreateProcess re-parses the Arguments string with the CommandLineToArgvW rules, and under those rules that is not enough: C:\My Folder\ -> "C:\My Folder\" the trailing backslash escapes the closing quote and the next argument is swallowed into it say "hi" there -> the embedded quotes end the quoting early (empty) -> the argument disappears tab-separated -> not quoted at all, split in two Quote per the rules instead: double the backslashes that precede a quote or the closing quote, escape embedded quotes, and quote any argument that is empty or contains whitespace or a quote. net48 has no ProcessStartInfo.ArgumentList, which would otherwise do this. Checked by round-tripping generated command lines back through CommandLineToArgvW: plain paths, paths with spaces, several such paths in one line, a trailing backslash, an embedded quote, doubled backslashes, and a mixture all parse back to the original argv. --- AffinityHook/Program.cs | 62 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/AffinityHook/Program.cs b/AffinityHook/Program.cs index 22d16ec..6018939 100644 --- a/AffinityHook/Program.cs +++ b/AffinityHook/Program.cs @@ -91,8 +91,6 @@ static int Main(string[] args) Console.WriteLine(); } - var forwardedArgs = string.Join(" ", args.Select(a => a.Contains(" ") ? "\"" + a + "\"" : a)); - Console.WriteLine($"Starting Affinity from: {affinityExe}"); Console.WriteLine($"Mode: {(detachMode ? "Detached" : "Attached")}"); @@ -100,7 +98,7 @@ static int Main(string[] args) var startInfo = new ProcessStartInfo { FileName = affinityExe, - Arguments = forwardedArgs, + Arguments = BuildArguments(args), WorkingDirectory = affinityDir, UseShellExecute = false, CreateNoWindow = false @@ -195,6 +193,64 @@ private static bool IsRunningOnWine() } } + private static readonly char[] ArgumentDelimiters = { ' ', '\t', '\n', '\v', '"' }; + + /// + /// Join arguments into a command line that CreateProcess will parse back + /// into the same arguments. + /// + /// + /// With UseShellExecute = false the Arguments string is handed to + /// CreateProcess and re-parsed using the CommandLineToArgvW rules, so an + /// unquoted path containing spaces arrives at the target as several + /// arguments. Affinity then reports "failed to open" once per fragment. + /// + private static string BuildArguments(string[] args) + { + var sb = new StringBuilder(); + for (int i = 0; i < args.Length; i++) + { + if (i > 0) sb.Append(' '); + sb.Append(QuoteArgument(args[i])); + } + return sb.ToString(); + } + + /// + /// Quote a single argument per the CommandLineToArgvW rules. + /// + /// + /// Backslashes are only special immediately before a quote, which is why + /// a plain Replace("\"", "\\\"") is not sufficient: a Windows path + /// ending in a separator, such as C:\My Folder\, would otherwise have its + /// closing quote escaped by the trailing backslash and swallow the next + /// argument. + /// + private static string QuoteArgument(string arg) + { + if (arg.Length > 0 && arg.IndexOfAny(ArgumentDelimiters) < 0) return arg; + + var sb = new StringBuilder(arg.Length + 2); + sb.Append('"'); + for (int i = 0; ; i++) + { + int backslashes = 0; + while (i < arg.Length && arg[i] == '\\') { backslashes++; i++; } + + if (i == arg.Length) + { + // double any trailing backslashes so they do not escape the closing quote + sb.Append('\\', backslashes * 2); + break; + } + + if (arg[i] == '"') sb.Append('\\', backslashes * 2 + 1).Append('"'); + else sb.Append('\\', backslashes).Append(arg[i]); + } + sb.Append('"'); + return sb.ToString(); + } + /// /// Inject using simple CreateRemoteThread + LoadLibrary /// This works on both Windows and Wine - we always use the native bootstrap