Skip to content

Auto-escape MSBuild special characters in MsBuildArgument #2729

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions src/BenchmarkDotNet/Jobs/Argument.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
using System.Text;
using JetBrains.Annotations;

namespace BenchmarkDotNet.Jobs
{
public abstract class Argument: IEquatable<Argument>
public abstract class Argument : IEquatable<Argument>
{
[PublicAPI] public string TextRepresentation { get; }

Expand Down Expand Up @@ -47,6 +49,43 @@ public MonoArgument(string value) : base(value)
[PublicAPI]
public class MsBuildArgument : Argument
{
public MsBuildArgument(string value) : base(value) { }
private static readonly Dictionary<char, string> MsBuildEscapes = new ()
{
{ '%', "%25" },
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If anyone has already escaped their arguments, this will break them. Is there a way to avoid any breaks?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great point — thanks for flagging that!

You're absolutely right: blindly escaping everything could break cases where users already passed escaped values like %3B or %25.

To handle this safely, I updated the implementation to:

  • Detect and preserve only escape sequences that match known MSBuild-escaped values (like %3B, %24, etc.)
  • This prevents double-escaping for cases where users already encoded special characters, while still escaping unescaped input correctly

Additionally, if preferred, I can also add a bool alreadyEscaped = false parameter to the MsBuildArgument constructor. That way:

  • Power users can bypass escaping entirely if they know their input is already safe
  • Default behavior remains user-friendly for most use cases

Let me know if you'd prefer we add that constructor overload as well. Happy to follow up with it!

Copy link
Collaborator

@timcassell timcassell May 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can get weird if someone puts 100%25, it's ambiguous to us whether it should be 100% or 100%25. I think it would be better to add a bool escapeSpecialChars = true param (or false default to preserve existing behavior), and don't try to parse for existing escapes.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, good point, thank you!

You're right, there's no reliable way to guess the user's intent in edge cases like 100%25. Maybe it's smarter to let the user to decide.

I've updated the implementation to:

  • Add a bool escapeSpecialChars = true parameter to the MsBuildArgument constructor
  • If true, the value is escaped
  • If false, the value is passed through exactly as provided (for pre-escaped input)

This avoids ambiguity and doesn't change the default behavior for existing users.

{ '$', "%24" },
{ '@', "%40" },
{ '\'', "%27" },
{ '(', "%28" },
{ ')', "%29" },
{ ';', "%3B" },
{ '?', "%3F" },
{ '*', "%2A" }
};

private static string EscapeMsBuildSpecialChars(string value)
{
if (string.IsNullOrEmpty(value))
return value;

var sb = new StringBuilder(value.Length);
foreach (char c in value)
{
if (MsBuildEscapes.TryGetValue(c, out var escaped))
sb.Append(escaped);
else
sb.Append(c);
}

return sb.ToString();
}
/// <summary>
/// Represents an MSBuild command-line argument.
/// <param name="value">The raw or escaped argument value (e.g., "/p:DefineConstants=TEST1;TEST2").</param>
/// <param name="escapeSpecialChars">
/// If true (default), special MSBuild characters like %, ;, *, etc. will be escaped.
/// If false, the value is used as-is — use this only if you're passing a fully pre-escaped string.
/// </param>
/// </summary>
public MsBuildArgument(string value, bool escapeSpecialChars = true) : base(escapeSpecialChars ? EscapeMsBuildSpecialChars(value) : value) { }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,27 @@ public void MultipleProcessesAreBuiltWithCorrectProperties()
CanExecute<PropertyDefine>(config);
}

[Fact]
public void EscapesSemicolonInDefineConstants()
{
var arg = new MsBuildArgument("/p:DefineConstants=TEST1;TEST2");
Assert.Equal("/p:DefineConstants=TEST1%3BTEST2", arg.ToString());
}

[Fact]
public void EscapesPercentSign()
{
var arg = new MsBuildArgument("/p:SomeValue=100%");
Assert.Equal("/p:SomeValue=100%25", arg.ToString());
}

[Fact]
public void DoesNotDoubleEscapeAlreadyEscapedPercent()
{
var arg = new MsBuildArgument("/p:SomeValue=100%25", false);
Assert.Equal("/p:SomeValue=100%25", arg.ToString());
}

public class PropertyDefine
{
private const bool customPropWasSet =
Expand Down