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