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)); + } + } +}