diff --git a/Duplicati/Library/Utility/Utility.cs b/Duplicati/Library/Utility/Utility.cs
index e4add3d27..95c7321bd 100644
--- a/Duplicati/Library/Utility/Utility.cs
+++ b/Duplicati/Library/Utility/Utility.cs
@@ -41,6 +41,8 @@ using System.Threading.Tasks;
using Duplicati.Library.Common.IO;
using Duplicati.StreamUtil;
+[assembly: InternalsVisibleTo("Duplicati.UnitTest")]
+
namespace Duplicati.Library.Utility
{
public static class Utility
@@ -1466,11 +1468,22 @@ namespace Duplicati.Library.Utility
/// A flag indicating if environment variables are allowed to be expanded
[return: NotNullIfNotNull("arg")]
public static string? WrapCommandLineElement(string? arg, bool allowEnvExpansion)
+ => WrapCommandLineElement(arg, allowEnvExpansion, OperatingSystem.IsWindows());
+
+ ///
+ /// Wraps a single argument in quotes suitable for passing on a commandline.
+ ///
+ /// The wrapped commandline element.
+ /// The argument to wrap.
+ /// A flag indicating if environment variables are allowed to be expanded.
+ /// A flag indicating if Windows commandline escaping should be used.
+ [return: NotNullIfNotNull("arg")]
+ internal static string? WrapCommandLineElement(string? arg, bool allowEnvExpansion, bool isWindows)
{
if (string.IsNullOrWhiteSpace(arg))
return arg;
- if (!OperatingSystem.IsWindows())
+ if (!isWindows)
{
// We could consider using single quotes that prevents all expansions
//if (!allowEnvExpansion)
@@ -1490,16 +1503,13 @@ namespace Duplicati.Library.Utility
}
else
{
- // Windows needs only needs " replaced with "",
- // but is prone to %var% expansion when used in
- // immediate mode (i.e. from command prompt)
+ // Windows needs " replaced with "", but is prone to %var%
+ // expansion when used in immediate mode (i.e. from command prompt)
// Fortunately it does not expand when processes
// are started from within .Net
+ if (!allowEnvExpansion)
+ arg = arg.Replace("%", "%%");
- // TODO: I have not found a way to avoid escaping %varname%,
- // and sadly it expands only if the variable exists
- // making it even rarer and harder to diagnose when
- // it happens
arg = arg.Replace(@"""", @"""""");
// Also fix the case where the argument ends with a slash
diff --git a/Duplicati/UnitTest/CommandLineWrappingTests.cs b/Duplicati/UnitTest/CommandLineWrappingTests.cs
new file mode 100644
index 000000000..8cb04e122
--- /dev/null
+++ b/Duplicati/UnitTest/CommandLineWrappingTests.cs
@@ -0,0 +1,61 @@
+// Copyright (C) 2026, The Duplicati Team
+// https://duplicati.com, hello@duplicati.com
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the "Software"),
+// to deal in the Software without restriction, including without limitation
+// the rights to use, copy, modify, merge, publish, distribute, sublicense,
+// and/or sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using Duplicati.Library.Utility;
+using NUnit.Framework;
+using Assert = NUnit.Framework.Legacy.ClassicAssert;
+
+namespace Duplicati.UnitTest
+{
+ public class CommandLineWrappingTests
+ {
+ [Test]
+ [Category("Utility")]
+ public static void WrapCommandLineElementEscapesWindowsPercentWhenEnvironmentExpansionIsDisabled()
+ {
+ Assert.AreEqual("\"abc%%TEMP%%def\"", Utility.WrapCommandLineElement("abc%TEMP%def", false, true));
+ Assert.AreEqual("\"100%%\"", Utility.WrapCommandLineElement("100%", false, true));
+ }
+
+ [Test]
+ [Category("Utility")]
+ public static void WrapCommandLineElementKeepsWindowsPercentWhenEnvironmentExpansionIsEnabled()
+ {
+ Assert.AreEqual("\"abc%TEMP%def\"", Utility.WrapCommandLineElement("abc%TEMP%def", true, true));
+ }
+
+ [Test]
+ [Category("Utility")]
+ public static void WrapCommandLineElementKeepsExistingWindowsQuotingBehavior()
+ {
+ Assert.AreEqual("\"has\"\"quote\"", Utility.WrapCommandLineElement("has\"quote", false, true));
+ Assert.AreEqual("\"C:\\Temp\\folder\\\\\"", Utility.WrapCommandLineElement("C:\\Temp\\folder\\", false, true));
+ }
+
+ [Test]
+ [Category("Utility")]
+ public static void WrapCommandLineElementRespectsUnixEnvironmentExpansionFlag()
+ {
+ Assert.AreEqual("\"$HOME/file\"", Utility.WrapCommandLineElement("$HOME/file", true, false));
+ Assert.AreEqual("\"\\$HOME/file\"", Utility.WrapCommandLineElement("$HOME/file", false, false));
+ }
+ }
+}