From ee371c7413f4bd456299a4d14dff2b0973376fd2 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 9 Jun 2026 16:29:02 +0200 Subject: [PATCH 1/9] Handle script modified filter This PR fixes a bug where a script could write a new filter, but the filter was never applied to the backup. This also fixes an issue where the filter string was not available in scripts or emails, except on the initial call. This PR updates the logic so the `run-script-after` and `run-script-post-backup` also have access to the filter string, if needed for reporting or other. This fixes #5143 --- Duplicati/Library/Main/Controller.cs | 185 +++++++++++------- .../Library/Main/Operation/BackupHandler.cs | 2 + .../Library/Modules/Builtin/RunScript.cs | 10 +- Duplicati/UnitTest/RunScriptTests.cs | 122 ++++++++++-- 4 files changed, 219 insertions(+), 100 deletions(-) diff --git a/Duplicati/Library/Main/Controller.cs b/Duplicati/Library/Main/Controller.cs index dc5620119..0ca46706c 100644 --- a/Duplicati/Library/Main/Controller.cs +++ b/Duplicati/Library/Main/Controller.cs @@ -114,7 +114,7 @@ namespace Duplicati.Library.Main } /// - public async Task BackupAsync(string[] inputsources, IFilter filter = null) + public async Task BackupAsync(string[] inputsources, IFilter inputFilter = null) { UsageReporter.Reporter.Report("USE_BACKEND", new Library.Utility.Uri(m_backendUrl).Scheme); UsageReporter.Reporter.Report("USE_COMPRESSION", m_options.CompressionModule); @@ -130,9 +130,8 @@ namespace Duplicati.Library.Main CheckAutoCompactInterval(); CheckAutoVacuumInterval(); - return await RunActionAsync(new BackupResults(), inputsources, filter, async (result, backendManager) => + return await RunActionAsync(new BackupResults(), inputsources, inputFilter, async (result, backendManager, filter) => { - using (var h = new Operation.BackupHandler(m_options, result)) await h.RunAsync(ExpandInputSources(inputsources, filter), backendManager, filter) .ConfigureAwait(false); @@ -148,9 +147,9 @@ namespace Duplicati.Library.Main } /// - public async Task RestoreAsync(string[] paths, IFilter filter = null) + public async Task RestoreAsync(string[] paths, IFilter inputFilter = null) { - return await RunActionAsync(new RestoreResults(), paths, filter, async (result, backendManager) => + return await RunActionAsync(new RestoreResults(), paths, inputFilter, async (result, backendManager, filter) => { using var restoreDestination = (m_options.Restorepath ?? "").StartsWith("@") @@ -186,9 +185,9 @@ namespace Duplicati.Library.Main } /// - public async Task RestoreControlFilesAsync(IEnumerable files = null, IFilter filter = null) + public async Task RestoreControlFilesAsync(IEnumerable files = null, IFilter inputFilter = null) { - return await RunActionAsync(new RestoreControlFilesResults(), filter, (result, backendManager) => + return await RunActionAsync(new RestoreControlFilesResults(), inputFilter, (result, backendManager, filter) => new Operation.RestoreControlFilesHandler(m_options, result) .RunAsync(files, backendManager, filter) ).ConfigureAwait(false); @@ -197,16 +196,16 @@ namespace Duplicati.Library.Main /// public async Task DeleteAsync() { - return await RunActionAsync(new DeleteResults(), (result, backendManager) => + return await RunActionAsync(new DeleteResults(), (result, backendManager, filter) => new Operation.DeleteHandler(m_options, result) .RunAsync(backendManager) ).ConfigureAwait(false); } /// - public async Task RepairAsync(IFilter filter = null) + public async Task RepairAsync(IFilter inputFilter = null) { - return await RunActionAsync(new RepairResults(), filter, (result, backendManager) => + return await RunActionAsync(new RepairResults(), inputFilter, (result, backendManager, filter) => new Operation.RepairHandler(m_options, result) .RunAsync(backendManager, filter) ).ConfigureAwait(false); @@ -214,25 +213,25 @@ namespace Duplicati.Library.Main /// public async Task ListFilesetsAsync() - => await RunActionAsync(new ListFilesetResults(), (result, backendManager) => + => await RunActionAsync(new ListFilesetResults(), (result, backendManager, _) => Operation.ListFilesetsHandler.RunAsync(m_options, result, backendManager) ).ConfigureAwait(false); /// public async Task ListFolderAsync(string[] folders, long offset, long limit, bool extendedData) - => await RunActionAsync(new ListFolderResults(), (result, _) => + => await RunActionAsync(new ListFolderResults(), (result, _, _) => Operation.ListFolderHandler.RunAsync(m_options, result, folders, offset, limit, extendedData) ).ConfigureAwait(false); /// public async Task ListFileVersionsAsync(string[] files, long offset, long limit) - => await RunActionAsync(new ListFileVersionsResults(), (result, backendManager) => + => await RunActionAsync(new ListFileVersionsResults(), (result, backendManager, _) => Operation.ListFileVersionsHandler.RunAsync(m_options, result, files, offset, limit) ).ConfigureAwait(false); /// - public async Task SearchEntriesAsync(string[] pathprefixes, IFilter filter, long offset, long limit, bool extendedData) - => await RunActionAsync(new SearchFilesResults(), filter, (result, backendManager) => + public async Task SearchEntriesAsync(string[] pathprefixes, IFilter inputFilter, long offset, long limit, bool extendedData) + => await RunActionAsync(new SearchFilesResults(), inputFilter, (result, backendManager, filter) => Operation.SearchEntriesHandler.RunAsync(m_options, result, pathprefixes, filter, offset, limit, extendedData) ).ConfigureAwait(false); @@ -245,18 +244,18 @@ namespace Duplicati.Library.Main => ListAsync(filterstring == null ? null : new string[] { filterstring }, null); /// - public async Task ListAsync(IEnumerable filterstrings, IFilter filter) + public async Task ListAsync(IEnumerable filterstrings, IFilter inputFilter) { - return await RunActionAsync(new ListResults(), filter, (result, backendManager) => + return await RunActionAsync(new ListResults(), inputFilter, (result, backendManager, filter) => new Operation.ListFilesHandler(m_options, result) .RunAsync(backendManager, filterstrings, filter) ).ConfigureAwait(false); } /// - public async Task ListControlFilesAsync(IEnumerable filterstrings, IFilter filter) + public async Task ListControlFilesAsync(IEnumerable filterstrings, IFilter inputFilter) { - return await RunActionAsync(new ListResults(), filter, (result, backendManager) => + return await RunActionAsync(new ListResults(), inputFilter, (result, backendManager, filter) => new Operation.ListControlFilesHandler(m_options, result) .RunAsync(backendManager, filterstrings, filter) ).ConfigureAwait(false); @@ -265,7 +264,7 @@ namespace Duplicati.Library.Main /// public async Task ListRemoteAsync() { - return await RunActionAsync(new ListRemoteResults(), async (result, backendManager) => + return await RunActionAsync(new ListRemoteResults(), async (result, backendManager, _) => { using var tf = File.Exists(m_options.Dbpath) ? null : new TempFile(); await using var db = await LocalDatabase.CreateLocalDatabaseAsync(((string)tf) ?? m_options.Dbpath, "list-remote", true, null, result.TaskControl.ProgressToken).ConfigureAwait(false); @@ -276,7 +275,7 @@ namespace Duplicati.Library.Main /// public async Task DeleteAllRemoteFilesAsync() { - return await RunActionAsync(new ListRemoteResults(), async (result, backendManager) => + return await RunActionAsync(new ListRemoteResults(), async (result, backendManager, _) => { result.OperationProgressUpdater.UpdatePhase(OperationPhase.Delete_Listing); { @@ -335,7 +334,7 @@ namespace Duplicati.Library.Main { CheckAutoVacuumInterval(); - return await RunActionAsync(new CompactResults(), (result, backendManager) => + return await RunActionAsync(new CompactResults(), (result, backendManager, _) => new Operation.CompactHandler(m_options, result) .RunAsync(backendManager) ).ConfigureAwait(false); @@ -344,7 +343,7 @@ namespace Duplicati.Library.Main /// public async Task SetLocksAsync() { - return await RunActionAsync(new SetLockResults(), (result, backendManager) => + return await RunActionAsync(new SetLockResults(), (result, backendManager, _) => new Operation.SetLocksHandler(m_options, result) .RunAsync(backendManager) ).ConfigureAwait(false); @@ -353,18 +352,18 @@ namespace Duplicati.Library.Main /// public async Task ReadLockInfoAsync() { - return await RunActionAsync(new ReadLockInfoResults(), (result, backendManager) => + return await RunActionAsync(new ReadLockInfoResults(), (result, backendManager, _) => new Operation.ReadLockInfoHandler(m_options, result) .RunAsync(backendManager) ).ConfigureAwait(false); } /// - public async Task UpdateDatabaseWithVersionsAsync(IFilter filter = null) + public async Task UpdateDatabaseWithVersionsAsync(IFilter inputFilter = null) { var filelistfilter = Operation.RestoreHandler.GetNumberedFilelistFilterDelegate(m_options.Time, m_options.Version, singleTimeMatch: true); - return await RunActionAsync(new RecreateDatabaseResults(), filter, async (result, backendManager) => + return await RunActionAsync(new RecreateDatabaseResults(), inputFilter, async (result, backendManager, filter) => { using (var h = new Operation.RecreateDatabaseHandler(m_options, result)) await h.RunUpdateAsync(backendManager, filter, filelistfilter, null) @@ -377,17 +376,17 @@ namespace Duplicati.Library.Main { var t = new string[] { targetpath }; - return await RunActionAsync(new CreateLogDatabaseResults(), t, async (result, backendManager) => + return await RunActionAsync(new CreateLogDatabaseResults(), t, async (result, backendManager, _) => await new Operation.CreateBugReportHandler(t[0], m_options, result).RunAsync() ).ConfigureAwait(false); } /// - public async Task ListChangesAsync(string baseVersion, string targetVersion, IEnumerable filterstrings = null, IFilter filter = null, Action>> callback = null) + public async Task ListChangesAsync(string baseVersion, string targetVersion, IEnumerable filterstrings = null, IFilter inputFilter = null, Action>> callback = null) { var t = new string[] { baseVersion, targetVersion }; - return await RunActionAsync(new ListChangesResults(), t, filter, async (result, backendManager) => + return await RunActionAsync(new ListChangesResults(), t, inputFilter, async (result, backendManager, filter) => await new Operation.ListChangesHandler(m_options, result) .RunAsync(t[0], t[1], backendManager, filterstrings, filter, callback) ).ConfigureAwait(false); @@ -396,7 +395,7 @@ namespace Duplicati.Library.Main /// public async Task ListAffectedAsync(List args, Action callback = null) { - return await RunActionAsync(new ListAffectedResults(), (result, backendManager) => + return await RunActionAsync(new ListAffectedResults(), (result, backendManager, _) => new Operation.ListAffected(m_options, result) .RunAsync(args, callback) ).ConfigureAwait(false); @@ -408,14 +407,14 @@ namespace Duplicati.Library.Main if (!m_options.RawOptions.ContainsKey("full-remote-verification")) m_options.RawOptions["full-remote-verification"] = "true"; - return await RunActionAsync(new TestResults(), (result, backendManager) => + return await RunActionAsync(new TestResults(), (result, backendManager, _) => new Operation.TestHandler(m_options, result) .RunAsync(samples, backendManager) ).ConfigureAwait(false); } /// - public async Task TestFilterAsync(string[] paths, IFilter filter = null) + public async Task TestFilterAsync(string[] paths, IFilter inputFilter = null) { m_options.RawOptions["dry-run"] = "true"; m_options.RawOptions["dbpath"] = "INVALID!"; @@ -424,7 +423,7 @@ namespace Duplicati.Library.Main var filtertag = Logging.Log.LogTagFromType(typeof(Operation.Backup.FileEnumerationProcess)); using (Logging.Log.StartScope(m_messageSink.WriteMessage, x => x.FilterTag.Contains(filtertag))) { - return await RunActionAsync(new TestFilterResults(), paths, filter, (result, backendManager) => + return await RunActionAsync(new TestFilterResults(), paths, inputFilter, (result, backendManager, filter) => new Operation.TestFilterHandler(m_options, result) .RunAsync(ExpandInputSources(paths, filter), filter) ).ConfigureAwait(false); @@ -434,33 +433,33 @@ namespace Duplicati.Library.Main /// public async Task SystemInfoAsync() { - return await RunActionAsync(new SystemInfoResults(), (result, backendManager) => + return await RunActionAsync(new SystemInfoResults(), (result, backendManager, _) => Operation.SystemInfoHandler.RunAsync(result) ).ConfigureAwait(false); } /// - public async Task PurgeFilesAsync(IFilter filter) + public async Task PurgeFilesAsync(IFilter inputFilter) { - return await RunActionAsync(new PurgeFilesResults(), (result, backendManager) => + return await RunActionAsync(new PurgeFilesResults(), (result, backendManager, filter) => new Operation.PurgeFilesHandler(m_options, result) .RunAsync(backendManager, filter) ).ConfigureAwait(false); } /// - public async Task ListBrokenFilesAsync(IFilter filter, Func callbackhandler = null) + public async Task ListBrokenFilesAsync(IFilter inputFilter, Func callbackhandler = null) { - return await RunActionAsync(new ListBrokenFilesResults(), (result, backendManager) => + return await RunActionAsync(new ListBrokenFilesResults(), (result, backendManager, filter) => new Operation.ListBrokenFilesHandler(m_options, result) .RunAsync(backendManager, filter, callbackhandler) ).ConfigureAwait(false); } /// - public async Task PurgeBrokenFilesAsync(IFilter filter) + public async Task PurgeBrokenFilesAsync(IFilter inputFilter) { - return await RunActionAsync(new PurgeBrokenFilesResults(), (result, backendManager) => + return await RunActionAsync(new PurgeBrokenFilesResults(), (result, backendManager, filter) => new Operation.PurgeBrokenFilesHandler(m_options, result) .RunAsync(backendManager, filter) ).ConfigureAwait(false); @@ -489,7 +488,7 @@ namespace Duplicati.Library.Main var filtertag = Logging.Log.LogTagFromType(DynamicLoader.GenericLoader.GetModule(sendMailModuleKey).GetType()); using (Logging.Log.StartScope(m_messageSink.WriteMessage, x => x.FilterTag.Contains(filtertag))) { - return await RunActionAsync(new SendMailResults(), (result, backendManager) => + return await RunActionAsync(new SendMailResults(), (result, backendManager, _) => Task.Run(() => { result.Lines = new string[0]; @@ -502,13 +501,13 @@ namespace Duplicati.Library.Main /// public async Task VacuumAsync() { - return await RunActionAsync(new VacuumResults(), (result, backendManager) => + return await RunActionAsync(new VacuumResults(), (result, backendManager, _) => new Operation.VacuumHandler(m_options, result) .RunAsync() ).ConfigureAwait(false); } - private Task RunActionAsync(T result, Func method) + private Task RunActionAsync(T result, Func method) where T : ISetCommonOptions, ITaskControlProvider, Logging.ILogDestination, IBasicResults, IBackendWriterProvider { var tmp = new string[0]; @@ -516,21 +515,21 @@ namespace Duplicati.Library.Main return RunActionAsync(result, tmp, tempfilter, method); } - private Task RunActionAsync(T result, string[] paths, Func method) + private Task RunActionAsync(T result, string[] paths, Func method) where T : ISetCommonOptions, ITaskControlProvider, Logging.ILogDestination, IBasicResults, IBackendWriterProvider { IFilter tempfilter = null; return RunActionAsync(result, paths, tempfilter, method); } - private Task RunActionAsync(T result, IFilter filter, Func method) + private Task RunActionAsync(T result, IFilter filter, Func method) where T : ISetCommonOptions, ITaskControlProvider, Logging.ILogDestination, IBasicResults, IBackendWriterProvider { var tmp = new string[0]; return RunActionAsync(result, tmp, filter, method); } - private async Task RunActionAsync(T result, string[] paths, IFilter filter, Func method) + private async Task RunActionAsync(T result, string[] paths, IFilter filter, Func method) where T : ISetCommonOptions, ITaskControlProvider, Logging.ILogDestination, IBasicResults, IBackendWriterProvider { OnOperationStarted?.Invoke(result); @@ -563,7 +562,7 @@ namespace Duplicati.Library.Main { backend.UpdateThrottleValues(m_options.MaxUploadPrSecond, m_options.MaxDownloadPrSecond); m_currentBackendManager = backend; - await method(result, backend).ConfigureAwait(false); + await method(result, backend, filter).ConfigureAwait(false); } finally { @@ -633,7 +632,7 @@ namespace Duplicati.Library.Main } } - OperationComplete(result, null); + OperationComplete(result, null, filter); Logging.Log.WriteInformationMessage(LOGTAG, "CompletedOperation", Strings.Controller.CompletedOperationMessage(m_options.MainAction)); @@ -661,7 +660,7 @@ namespace Duplicati.Library.Main } // Do not propagate the cancel exception - OperationComplete(result, null); + OperationComplete(result, null, filter); return result; } @@ -686,7 +685,7 @@ namespace Duplicati.Library.Main } // Report the result, and the failure - OperationComplete(result, ex); + OperationComplete(result, ex, filter); throw; } @@ -697,8 +696,45 @@ namespace Duplicati.Library.Main } } - private void OperationComplete(IBasicResults result, Exception exception) + /// + /// Temporarily pushes the filter into the raw options so modules can read it. + /// Returns a disposable that removes the key when disposed. + /// + /// The options to push the filter into + /// The filter to push + /// A disposable that removes the key when disposed + internal static IDisposable PushFilterToOptions(Options options, IFilter filter) + => PushFilterToOptions(options, filter, out var _); + /// + /// Temporarily pushes the filter into the raw options so modules can read it. + /// Returns a disposable that removes the key when disposed. + /// + /// The options to push the filter into + /// The filter to push + /// A disposable that removes the key when disposed + internal static IDisposable PushFilterToOptions(Options options, IFilter filter, out string filterString) { + filterString = filter != null && !filter.Empty + ? string.Join(Path.PathSeparator.ToString(), FilterExpression.Serialize(filter)) + : null; + + options.RawOptions["filter"] = filterString; + return new FilterOptionScope(options); + } + + /// + /// Helper to remove the filter option on dispose + /// + private sealed class FilterOptionScope : IDisposable + { + private readonly Options m_opts; + public FilterOptionScope(Options opts) => m_opts = opts; + public void Dispose() => m_opts?.RawOptions.Remove("filter"); + } + + private void OperationComplete(IBasicResults result, Exception exception, IFilter filter) + { + using var _ = PushFilterToOptions(m_options, filter); if (m_options?.LoadedModules != null) { foreach (var mx in m_options.LoadedModules) @@ -756,45 +792,42 @@ namespace Duplicati.Library.Main m_options.AddLoadedModule(DynamicLoader.GenericLoader.GetModule(m.Key)); } - // Make the filter read-n-write able in the generic modules - var pristinefilter = string.Join(Path.PathSeparator.ToString(), FilterExpression.Serialize(filter)); - m_options.RawOptions["filter"] = pristinefilter; - // Store the URL connection options separately, as these should only be visible to modules implementing IConnectionModule var conopts = new Dictionary(m_options.RawOptions); var qp = new Library.Utility.Uri(m_backendUrl).QueryParameters; foreach (var k in qp.Keys) conopts[(string)k] = qp[(string)k]; - foreach (var mx in m_options.LoadedModules) + // Make the filter read-n-write able in the generic modules + using (PushFilterToOptions(m_options, filter, out var pristinefilter)) { - if (mx is IConnectionModule) - mx.Configure(conopts); - else - mx.Configure(m_options.RawOptions); - - if (mx is IGenericSourceModule sourcemodule) + foreach (var mx in m_options.LoadedModules) { - if (sourcemodule.ContainFilesForBackup(paths)) - { - var sourceoptions = sourcemodule.ParseSourcePaths(ref paths, ref pristinefilter, m_options.RawOptions); + if (mx is IConnectionModule) + mx.Configure(conopts); + else + mx.Configure(m_options.RawOptions); - foreach (var sourceoption in sourceoptions) - m_options.RawOptions[sourceoption.Key] = sourceoption.Value; + if (mx is IGenericSourceModule sourcemodule) + { + if (sourcemodule.ContainFilesForBackup(paths)) + { + var sourceoptions = sourcemodule.ParseSourcePaths(ref paths, ref pristinefilter, m_options.RawOptions); + + foreach (var sourceoption in sourceoptions) + m_options.RawOptions[sourceoption.Key] = sourceoption.Value; + } } + + if (mx is IGenericCallbackModule module) + module.OnStart(result.MainOperation.ToString(), ref m_backendUrl, ref paths); } - if (mx is IGenericCallbackModule module) - module.OnStart(result.MainOperation.ToString(), ref m_backendUrl, ref paths); + // If the filters were changed by a module, read them back in + if (pristinefilter != m_options.RawOptions["filter"]) + filter = FilterExpression.Deserialize(m_options.RawOptions["filter"].Split(new string[] { Path.PathSeparator.ToString() }, StringSplitOptions.RemoveEmptyEntries)); } - // If the filters were changed by a module, read them back in - if (pristinefilter != m_options.RawOptions["filter"]) - { - filter = FilterExpression.Deserialize(m_options.RawOptions["filter"].Split(new string[] { Path.PathSeparator.ToString() }, StringSplitOptions.RemoveEmptyEntries)); - } - m_options.RawOptions.Remove("filter"); // "--filter" is not a supported command line option - if (!string.IsNullOrEmpty(m_options.Logfile)) { var path = Path.GetDirectoryName(Path.GetFullPath(m_options.Logfile)); diff --git a/Duplicati/Library/Main/Operation/BackupHandler.cs b/Duplicati/Library/Main/Operation/BackupHandler.cs index 5a3f2287e..bb4218d89 100644 --- a/Duplicati/Library/Main/Operation/BackupHandler.cs +++ b/Duplicati/Library/Main/Operation/BackupHandler.cs @@ -899,6 +899,8 @@ namespace Duplicati.Library.Main.Operation // and sources are disposed, but before verification if (await m_result.TaskControl.ProgressRendevouzAsync().ConfigureAwait(false) && m_options.LoadedModules != null) { + // Make filter available to modules + using var _ = Controller.PushFilterToOptions(m_options, filter); foreach (var mx in m_options.LoadedModules) if (mx is IBackupCallbackModule module) try { module.OnFinishBackup(m_result, null); } diff --git a/Duplicati/Library/Modules/Builtin/RunScript.cs b/Duplicati/Library/Modules/Builtin/RunScript.cs index 92b1c03a7..063cee1ef 100644 --- a/Duplicati/Library/Modules/Builtin/RunScript.cs +++ b/Duplicati/Library/Modules/Builtin/RunScript.cs @@ -265,13 +265,11 @@ namespace Duplicati.Library.Modules.Builtin else level = ParsedResultType.Error; - using (TempFile tmpfile = new TempFile()) - { - using (var streamWriter = new StreamWriter(tmpfile)) - streamWriter.Write(resultFormatSerializer.Serialize(result, exception, m_logstorage, null)); + using var tmpfile = new TempFile(); + using (var streamWriter = new StreamWriter(tmpfile)) + streamWriter.Write(resultFormatSerializer.Serialize(result, exception, m_logstorage, null)); - Execute(m_finishScript, "AFTER", m_operationName, ref m_remoteurl, ref m_localpath, m_timeout, false, m_enableArguments, m_options, tmpfile, level); - } + Execute(m_finishScript, "AFTER", m_operationName, ref m_remoteurl, ref m_localpath, m_timeout, false, m_enableArguments, m_options, tmpfile, level); } /// diff --git a/Duplicati/UnitTest/RunScriptTests.cs b/Duplicati/UnitTest/RunScriptTests.cs index 3e8b5150b..726ac217f 100644 --- a/Duplicati/UnitTest/RunScriptTests.cs +++ b/Duplicati/UnitTest/RunScriptTests.cs @@ -96,9 +96,6 @@ namespace Duplicati.UnitTest options["blocksize"] = blocksize.ToString() + "b"; options["run-script-timeout"] = "5s"; - // We need a small delay as we run very small backups back-to-back - var PAUSE_TIME = TimeSpan.FromSeconds(3); - BorderTests.WriteTestFilesToFolder(DATAFOLDER, blocksize, 0); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) @@ -110,7 +107,6 @@ namespace Duplicati.UnitTest throw new Exception("Unexpected result from base backup"); } - System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(0); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { @@ -121,7 +117,6 @@ namespace Duplicati.UnitTest throw new Exception("Backup did not examine any files for code 0?"); } - System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(1); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { @@ -132,7 +127,6 @@ namespace Duplicati.UnitTest throw new Exception("Backup did examine files for code 1?"); } - System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(2); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { @@ -143,7 +137,6 @@ namespace Duplicati.UnitTest throw new Exception("Backup did not examine any files for code 2?"); } - System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(3); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { @@ -154,7 +147,6 @@ namespace Duplicati.UnitTest throw new Exception("Backup did examine files for code 3?"); } - System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(4); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { @@ -167,7 +159,6 @@ namespace Duplicati.UnitTest foreach (int exitCode in new[] { 5, 6, 10, 99 }) { - System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(exitCode); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { @@ -179,7 +170,6 @@ namespace Duplicati.UnitTest } } - System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(2, "TEST WARNING MESSAGE"); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { @@ -192,7 +182,6 @@ namespace Duplicati.UnitTest throw new Exception("Found no warning message in output for code 2"); } - System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(3, "TEST WARNING MESSAGE"); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { @@ -205,7 +194,6 @@ namespace Duplicati.UnitTest throw new Exception("Found no warning message in output for code 3"); } - System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(4, "TEST ERROR MESSAGE"); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { @@ -218,7 +206,6 @@ namespace Duplicati.UnitTest throw new Exception("Found no error message in output for code 4"); } - System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(5, "TEST ERROR MESSAGE"); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { @@ -231,7 +218,6 @@ namespace Duplicati.UnitTest throw new Exception("Found no error message in output for code 5"); } - System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(0, sleeptime: 10); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { @@ -381,6 +367,109 @@ namespace Duplicati.UnitTest Assert.AreEqual(3, customTargetEntries.Count()); } + [Test] + [Category("Border")] + public async Task CustomFilterAsync() + { + var expectedFilter = "+[^(.*b.*)$]"; + var parsedResultFileAfter = Path.Combine(this.RESTOREFOLDER, "result_after.txt"); + var parsedResultFilePostBackup = Path.Combine(this.RESTOREFOLDER, "result_post_backup.txt"); + + var customCommandsBefore = new List + { + $"echo --filter=\"{expectedFilter}\"" + }; + + var customCommandsAfter = new List(); + if (OperatingSystem.IsWindows()) + { + customCommandsAfter.Add($"echo %DUPLICATI__filter%>\"{parsedResultFileAfter}\""); + } + else + { + customCommandsAfter.Add($"echo \"$DUPLICATI__filter\">\"{parsedResultFileAfter}\""); + } + + var customCommandsPostBackup = new List(); + if (OperatingSystem.IsWindows()) + { + customCommandsPostBackup.Add($"echo %DUPLICATI__filter%>\"{parsedResultFilePostBackup}\""); + } + else + { + customCommandsPostBackup.Add($"echo \"$DUPLICATI__filter\">\"{parsedResultFilePostBackup}\""); + } + + var options = new Dictionary(this.TestOptions); + options["run-script-before"] = CreateScript(0, null, null, 0, customCommandsBefore); + options["run-script-after"] = CreateScript(0, null, null, 0, customCommandsAfter); + options["run-script-post-backup"] = CreateScript(0, null, null, 0, customCommandsPostBackup); + + using (var c = new Library.Main.Controller("file://" + this.TARGETFOLDER, options, null)) + { + var backupResults = await c.BackupAsync(new[] { this.DATAFOLDER }); + Assert.AreEqual(0, backupResults.Errors.Count()); + Assert.AreEqual(0, backupResults.Warnings.Count()); + } + + var linesAfter = File.ReadAllLines(parsedResultFileAfter); + Assert.AreEqual(1, linesAfter.Length); + Assert.AreEqual(expectedFilter, linesAfter[0]); + + var linesPostBackup = File.ReadAllLines(parsedResultFilePostBackup); + Assert.AreEqual(1, linesPostBackup.Length); + Assert.AreEqual(expectedFilter, linesPostBackup[0]); + } + + [Test] + [Category("Border")] + public async Task CommandLineFilterSeenByScriptsAsync() + { + var expectedFilter = "+*B*"; + var parsedResultFileAfter = Path.Combine(this.RESTOREFOLDER, "result_after_cmdline.txt"); + var parsedResultFilePostBackup = Path.Combine(this.RESTOREFOLDER, "result_post_backup_cmdline.txt"); + + var customCommandsAfter = new List(); + if (OperatingSystem.IsWindows()) + { + customCommandsAfter.Add($"echo %DUPLICATI__filter%>\"{parsedResultFileAfter}\""); + } + else + { + customCommandsAfter.Add($"echo \"$DUPLICATI__filter\">\"{parsedResultFileAfter}\""); + } + + var customCommandsPostBackup = new List(); + if (OperatingSystem.IsWindows()) + { + customCommandsPostBackup.Add($"echo %DUPLICATI__filter%>\"{parsedResultFilePostBackup}\""); + } + else + { + customCommandsPostBackup.Add($"echo \"$DUPLICATI__filter\">\"{parsedResultFilePostBackup}\""); + } + + var options = new Dictionary(this.TestOptions); + options["run-script-after"] = CreateScript(0, null, null, 0, customCommandsAfter); + options["run-script-post-backup"] = CreateScript(0, null, null, 0, customCommandsPostBackup); + + using (var c = new Library.Main.Controller("file://" + this.TARGETFOLDER, options, null)) + { + var filter = new Library.Utility.FilterExpression("*b*", true); + var backupResults = await c.BackupAsync(new[] { this.DATAFOLDER }, filter); + Assert.AreEqual(0, backupResults.Errors.Count()); + Assert.AreEqual(0, backupResults.Warnings.Count()); + } + + var linesAfter = File.ReadAllLines(parsedResultFileAfter); + Assert.AreEqual(1, linesAfter.Length); + Assert.AreEqual(expectedFilter, linesAfter[0]); + + var linesPostBackup = File.ReadAllLines(parsedResultFilePostBackup); + Assert.AreEqual(1, linesPostBackup.Length); + Assert.AreEqual(expectedFilter, linesPostBackup[0]); + } + private string CreateScript(int exitcode, string stderr = null, string stdout = null, int sleeptime = 0, List customCommands = null) { var id = Guid.NewGuid().ToString("N").Substring(0, 6); @@ -403,13 +492,10 @@ namespace Duplicati.UnitTest } else { - var commands = new List(); - commands.Add("#!/bin/sh"); + var commands = new List() { "#!/bin/sh" }; if (customCommands != null) - { commands.AddRange(customCommands); - } if (!string.IsNullOrWhiteSpace(stdout)) commands.Add($@"echo {stdout}"); From 933c8924c8deb7ee1e685f583257fbda9192e1bc Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Wed, 10 Jun 2026 14:18:06 +0200 Subject: [PATCH 2/9] Fixed filter not being passed to soem controller methods --- Duplicati/Library/Main/Controller.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Duplicati/Library/Main/Controller.cs b/Duplicati/Library/Main/Controller.cs index 0ca46706c..f2fc8560b 100644 --- a/Duplicati/Library/Main/Controller.cs +++ b/Duplicati/Library/Main/Controller.cs @@ -441,7 +441,7 @@ namespace Duplicati.Library.Main /// public async Task PurgeFilesAsync(IFilter inputFilter) { - return await RunActionAsync(new PurgeFilesResults(), (result, backendManager, filter) => + return await RunActionAsync(new PurgeFilesResults(), inputFilter, (result, backendManager, filter) => new Operation.PurgeFilesHandler(m_options, result) .RunAsync(backendManager, filter) ).ConfigureAwait(false); @@ -450,7 +450,7 @@ namespace Duplicati.Library.Main /// public async Task ListBrokenFilesAsync(IFilter inputFilter, Func callbackhandler = null) { - return await RunActionAsync(new ListBrokenFilesResults(), (result, backendManager, filter) => + return await RunActionAsync(new ListBrokenFilesResults(), inputFilter, (result, backendManager, filter) => new Operation.ListBrokenFilesHandler(m_options, result) .RunAsync(backendManager, filter, callbackhandler) ).ConfigureAwait(false); @@ -459,7 +459,7 @@ namespace Duplicati.Library.Main /// public async Task PurgeBrokenFilesAsync(IFilter inputFilter) { - return await RunActionAsync(new PurgeBrokenFilesResults(), (result, backendManager, filter) => + return await RunActionAsync(new PurgeBrokenFilesResults(), inputFilter, (result, backendManager, filter) => new Operation.PurgeBrokenFilesHandler(m_options, result) .RunAsync(backendManager, filter) ).ConfigureAwait(false); From c65dc52fc638d6521886904415ec276432cb96f9 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 12 Jun 2026 16:37:08 +0200 Subject: [PATCH 3/9] Path traversal now logs a warning instead of crashing the recovery restore. --- Duplicati/CommandLine/RecoveryTool/Restore.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Duplicati/CommandLine/RecoveryTool/Restore.cs b/Duplicati/CommandLine/RecoveryTool/Restore.cs index 0bdf5d9df..c62f60af4 100644 --- a/Duplicati/CommandLine/RecoveryTool/Restore.cs +++ b/Duplicati/CommandLine/RecoveryTool/Restore.cs @@ -156,7 +156,11 @@ namespace Duplicati.CommandLine.RecoveryTool var targetfile = MapToRestorePath(f.Path, largestprefix, targetpath, sourceDirsep); if (!allowPathTraversal && !string.IsNullOrWhiteSpace(targetpath) && !Util.IsPathInsideTarget(targetfile, targetpath)) - throw new UserInformationException($"Path traversal detected: {targetfile} resolves outside {targetpath}", "RestorePathTraversal"); + { + Console.WriteLine($"WARNING: Path traversal detected: {targetfile} resolves outside {targetpath}, skipping entry. Use --allow-restore-outside-target-directory if you are sure this is safe."); + errorCount++; + continue; + } if (!systemIO.DirectoryExists(systemIO.PathGetDirectoryName(targetfile))) systemIO.DirectoryCreate(systemIO.PathGetDirectoryName(targetfile)); From c5e621434c2f871976f263e76633ae422431cdff Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 12 Jun 2026 16:37:41 +0200 Subject: [PATCH 4/9] Simplify test to avoid quirks in how filters are handled internally across platforms. --- Duplicati/UnitTest/RunScriptTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Duplicati/UnitTest/RunScriptTests.cs b/Duplicati/UnitTest/RunScriptTests.cs index 726ac217f..4ca2e390e 100644 --- a/Duplicati/UnitTest/RunScriptTests.cs +++ b/Duplicati/UnitTest/RunScriptTests.cs @@ -371,7 +371,7 @@ namespace Duplicati.UnitTest [Category("Border")] public async Task CustomFilterAsync() { - var expectedFilter = "+[^(.*b.*)$]"; + var expectedFilter = ".*b.*"; var parsedResultFileAfter = Path.Combine(this.RESTOREFOLDER, "result_after.txt"); var parsedResultFilePostBackup = Path.Combine(this.RESTOREFOLDER, "result_post_backup.txt"); @@ -414,18 +414,18 @@ namespace Duplicati.UnitTest var linesAfter = File.ReadAllLines(parsedResultFileAfter); Assert.AreEqual(1, linesAfter.Length); - Assert.AreEqual(expectedFilter, linesAfter[0]); + Assert.IsTrue(linesAfter[0].Contains(expectedFilter, StringComparison.OrdinalIgnoreCase)); var linesPostBackup = File.ReadAllLines(parsedResultFilePostBackup); Assert.AreEqual(1, linesPostBackup.Length); - Assert.AreEqual(expectedFilter, linesPostBackup[0]); + Assert.IsTrue(linesPostBackup[0].Contains(expectedFilter, StringComparison.OrdinalIgnoreCase)); } [Test] [Category("Border")] public async Task CommandLineFilterSeenByScriptsAsync() { - var expectedFilter = "+*B*"; + var expectedFilter = "+*b*"; var parsedResultFileAfter = Path.Combine(this.RESTOREFOLDER, "result_after_cmdline.txt"); var parsedResultFilePostBackup = Path.Combine(this.RESTOREFOLDER, "result_post_backup_cmdline.txt"); @@ -463,11 +463,11 @@ namespace Duplicati.UnitTest var linesAfter = File.ReadAllLines(parsedResultFileAfter); Assert.AreEqual(1, linesAfter.Length); - Assert.AreEqual(expectedFilter, linesAfter[0]); + Assert.IsTrue(linesAfter[0].Contains(expectedFilter, StringComparison.OrdinalIgnoreCase)); var linesPostBackup = File.ReadAllLines(parsedResultFilePostBackup); Assert.AreEqual(1, linesPostBackup.Length); - Assert.AreEqual(expectedFilter, linesPostBackup[0]); + Assert.IsTrue(linesPostBackup[0].Contains(expectedFilter, StringComparison.OrdinalIgnoreCase)); } private string CreateScript(int exitcode, string stderr = null, string stdout = null, int sleeptime = 0, List customCommands = null) From 1f155da26ae1208df40dedb36c19f170f079848c Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 12 Jun 2026 17:15:55 +0200 Subject: [PATCH 5/9] Added test that verifies paths can be modified by writing --localpath options back --- Duplicati/UnitTest/RunScriptTests.cs | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/Duplicati/UnitTest/RunScriptTests.cs b/Duplicati/UnitTest/RunScriptTests.cs index 4ca2e390e..6a43a7ea0 100644 --- a/Duplicati/UnitTest/RunScriptTests.cs +++ b/Duplicati/UnitTest/RunScriptTests.cs @@ -367,6 +367,44 @@ namespace Duplicati.UnitTest Assert.AreEqual(3, customTargetEntries.Count()); } + [Test] + [Category("Border")] + public async Task CustomLocalPathAsync() + { + var customSourceFolder = Path.Combine(this.DATAFOLDER, "source2"); + Directory.CreateDirectory(customSourceFolder); + var expectedFile = Path.Combine(customSourceFolder, "file2.txt"); + File.WriteAllText(expectedFile, "some content"); + + var customCommands = new List + { + $"echo --localpath=\"{customSourceFolder}\"" + }; + + var options = new Dictionary(this.TestOptions); + options["run-script-before"] = CreateScript(0, null, null, 0, customCommands); + using (var c = new Library.Main.Controller("file://" + this.TARGETFOLDER, options, null)) + { + var backupResults = await c.BackupAsync(new[] { this.DATAFOLDER }); + Assert.AreEqual(0, backupResults.Errors.Count()); + Assert.AreEqual(0, backupResults.Warnings.Count()); + Assert.AreEqual(1, backupResults.ExaminedFiles); + } + + using (var c = new Library.Main.Controller("file://" + this.TARGETFOLDER, this.TestOptions, null)) + { + // Restore to a specific folder so we don't mess up original, + // or just restore exactly since it's a test environment. + var restoreOptions = new Dictionary(this.TestOptions); + restoreOptions["restore-path"] = this.RESTOREFOLDER; + var c2 = new Library.Main.Controller("file://" + this.TARGETFOLDER, restoreOptions, null); + var restoreResults = await c2.RestoreAsync(new[] { expectedFile }); + Assert.AreEqual(0, restoreResults.Errors.Count()); + Assert.AreEqual(0, restoreResults.Warnings.Count()); + Assert.AreEqual(1, restoreResults.RestoredFiles); + } + } + [Test] [Category("Border")] public async Task CustomFilterAsync() From ee92633b8061c157c5492028222042fe4380eb09 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 12 Jun 2026 17:41:17 +0200 Subject: [PATCH 6/9] Reworked parameter mapping and passing inside Controller.cs to avoid future issues with lambda captures that prevent module modifications from being applied. --- Duplicati/Library/Main/Controller.cs | 267 +++++++++--------- .../Library/Main/Operation/ListAffected.cs | 2 +- 2 files changed, 128 insertions(+), 141 deletions(-) diff --git a/Duplicati/Library/Main/Controller.cs b/Duplicati/Library/Main/Controller.cs index f2fc8560b..a56bf6ff1 100644 --- a/Duplicati/Library/Main/Controller.cs +++ b/Duplicati/Library/Main/Controller.cs @@ -130,17 +130,17 @@ namespace Duplicati.Library.Main CheckAutoCompactInterval(); CheckAutoVacuumInterval(); - return await RunActionAsync(new BackupResults(), inputsources, inputFilter, async (result, backendManager, filter) => + return await RunActionAsync(new BackupResults(), inputsources, inputFilter, false, static async config => { - using (var h = new Operation.BackupHandler(m_options, result)) - await h.RunAsync(ExpandInputSources(inputsources, filter), backendManager, filter) + using (var h = new Operation.BackupHandler(config.Options, config.Result)) + await h.RunAsync(ExpandInputSources(config.Paths, config.Filter, config.Options), config.BackendManager, config.Filter) .ConfigureAwait(false); - UsageReporter.Reporter.Report("BACKUP_FILECOUNT", result.ExaminedFiles); - UsageReporter.Reporter.Report("BACKUP_FILESIZE", result.SizeOfExaminedFiles); - UsageReporter.Reporter.Report("BACKUP_DURATION", (long)result.Duration.TotalSeconds); + UsageReporter.Reporter.Report("BACKUP_FILECOUNT", config.Result.ExaminedFiles); + UsageReporter.Reporter.Report("BACKUP_FILESIZE", config.Result.SizeOfExaminedFiles); + UsageReporter.Reporter.Report("BACKUP_DURATION", (long)config.Result.Duration.TotalSeconds); - using (var h = new Operation.RemoteSynchronizationHandler(m_backendUrl, m_options, result)) + using (var h = new Operation.RemoteSynchronizationHandler(config.BackendUrl, config.Options, config.Result)) await h.RunAsync() .ConfigureAwait(false); }).ConfigureAwait(false); @@ -149,90 +149,90 @@ namespace Duplicati.Library.Main /// public async Task RestoreAsync(string[] paths, IFilter inputFilter = null) { - return await RunActionAsync(new RestoreResults(), paths, inputFilter, async (result, backendManager, filter) => + return await RunActionAsync(new RestoreResults(), paths, inputFilter, false, static async config => { using var restoreDestination = - (m_options.Restorepath ?? "").StartsWith("@") + (config.Options.Restorepath ?? "").StartsWith("@") // Remote destination ? await DynamicLoader.RestoreDestinationProviderLoader.GetRestoreDestinationProvider( - m_options.Restorepath.Substring(1), - m_options.RawOptions, - result.TaskControl.ProgressToken) + config.Options.Restorepath.Substring(1), + config.Options.RawOptions, + config.Result.TaskControl.ProgressToken) .ConfigureAwait(false) // Local destination - : new SourceProvider.FileRestoreDestinationProvider(m_options.Restorepath ?? "", m_options.AllowRestoreOutsideTargetDirectory); + : new SourceProvider.FileRestoreDestinationProvider(config.Options.Restorepath ?? "", config.Options.AllowRestoreOutsideTargetDirectory); if (restoreDestination == null) - throw new UserInformationException($"Could not find restore destination for path: {m_options.Restorepath}", "InvalidRestoreDestination"); + throw new UserInformationException($"Could not find restore destination for path: {config.Options.Restorepath}", "InvalidRestoreDestination"); - await new Operation.RestoreHandler(m_options, result) - .RunAsync(paths, backendManager, filter, restoreDestination) + await new Operation.RestoreHandler(config.Options, config.Result) + .RunAsync(config.Paths, config.BackendManager, config.Filter, restoreDestination) .ConfigureAwait(false); await restoreDestination.Finalize((pg) => { - result.OperationProgressUpdater.UpdateProgress((float)pg); - }, result.TaskControl.ProgressToken).ConfigureAwait(false); - result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_Complete); - result.EndTime = DateTime.UtcNow; + config.Result.OperationProgressUpdater.UpdateProgress((float)pg); + }, config.Result.TaskControl.ProgressToken).ConfigureAwait(false); + config.Result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_Complete); + config.Result.EndTime = DateTime.UtcNow; - UsageReporter.Reporter.Report("RESTORE_FILECOUNT", result.RestoredFiles); - UsageReporter.Reporter.Report("RESTORE_FILESIZE", result.SizeOfRestoredFiles); - UsageReporter.Reporter.Report("RESTORE_DURATION", (long)result.Duration.TotalSeconds); + UsageReporter.Reporter.Report("RESTORE_FILECOUNT", config.Result.RestoredFiles); + UsageReporter.Reporter.Report("RESTORE_FILESIZE", config.Result.SizeOfRestoredFiles); + UsageReporter.Reporter.Report("RESTORE_DURATION", (long)config.Result.Duration.TotalSeconds); }).ConfigureAwait(false); } /// public async Task RestoreControlFilesAsync(IEnumerable files = null, IFilter inputFilter = null) { - return await RunActionAsync(new RestoreControlFilesResults(), inputFilter, (result, backendManager, filter) => - new Operation.RestoreControlFilesHandler(m_options, result) - .RunAsync(files, backendManager, filter) + return await RunActionAsync(new RestoreControlFilesResults(), files?.ToArray(), inputFilter, false, static config => + new Operation.RestoreControlFilesHandler(config.Options, config.Result) + .RunAsync(config.Paths, config.BackendManager, config.Filter) ).ConfigureAwait(false); } /// public async Task DeleteAsync() { - return await RunActionAsync(new DeleteResults(), (result, backendManager, filter) => - new Operation.DeleteHandler(m_options, result) - .RunAsync(backendManager) + return await RunActionAsync(new DeleteResults(), null, null, false, static config => + new Operation.DeleteHandler(config.Options, config.Result) + .RunAsync(config.BackendManager) ).ConfigureAwait(false); } /// public async Task RepairAsync(IFilter inputFilter = null) { - return await RunActionAsync(new RepairResults(), inputFilter, (result, backendManager, filter) => - new Operation.RepairHandler(m_options, result) - .RunAsync(backendManager, filter) + return await RunActionAsync(new RepairResults(), null, inputFilter, false, static config => + new Operation.RepairHandler(config.Options, config.Result) + .RunAsync(config.BackendManager, config.Filter) ).ConfigureAwait(false); } /// public async Task ListFilesetsAsync() - => await RunActionAsync(new ListFilesetResults(), (result, backendManager, _) => - Operation.ListFilesetsHandler.RunAsync(m_options, result, backendManager) + => await RunActionAsync(new ListFilesetResults(), null, null, false, static config => + Operation.ListFilesetsHandler.RunAsync(config.Options, config.Result, config.BackendManager) ).ConfigureAwait(false); /// public async Task ListFolderAsync(string[] folders, long offset, long limit, bool extendedData) - => await RunActionAsync(new ListFolderResults(), (result, _, _) => - Operation.ListFolderHandler.RunAsync(m_options, result, folders, offset, limit, extendedData) + => await RunActionAsync(new ListFolderResults(), folders, null, new { offset, limit, extendedData }, static config => + Operation.ListFolderHandler.RunAsync(config.Options, config.Result, config.Paths, config.Context.offset, config.Context.limit, config.Context.extendedData) ).ConfigureAwait(false); /// public async Task ListFileVersionsAsync(string[] files, long offset, long limit) - => await RunActionAsync(new ListFileVersionsResults(), (result, backendManager, _) => - Operation.ListFileVersionsHandler.RunAsync(m_options, result, files, offset, limit) + => await RunActionAsync(new ListFileVersionsResults(), files, null, new { offset, limit }, static config => + Operation.ListFileVersionsHandler.RunAsync(config.Options, config.Result, config.Paths, config.Context.offset, config.Context.limit) ).ConfigureAwait(false); /// public async Task SearchEntriesAsync(string[] pathprefixes, IFilter inputFilter, long offset, long limit, bool extendedData) - => await RunActionAsync(new SearchFilesResults(), inputFilter, (result, backendManager, filter) => - Operation.SearchEntriesHandler.RunAsync(m_options, result, pathprefixes, filter, offset, limit, extendedData) + => await RunActionAsync(new SearchFilesResults(), pathprefixes, inputFilter, new { offset, limit, extendedData }, static config => + Operation.SearchEntriesHandler.RunAsync(config.Options, config.Result, config.Paths, config.Filter, config.Context.offset, config.Context.limit, config.Context.extendedData) ).ConfigureAwait(false); /// @@ -246,85 +246,85 @@ namespace Duplicati.Library.Main /// public async Task ListAsync(IEnumerable filterstrings, IFilter inputFilter) { - return await RunActionAsync(new ListResults(), inputFilter, (result, backendManager, filter) => - new Operation.ListFilesHandler(m_options, result) - .RunAsync(backendManager, filterstrings, filter) + return await RunActionAsync(new ListResults(), filterstrings?.ToArray(), inputFilter, false, static config => + new Operation.ListFilesHandler(config.Options, config.Result) + .RunAsync(config.BackendManager, config.Paths, config.Filter) ).ConfigureAwait(false); } /// public async Task ListControlFilesAsync(IEnumerable filterstrings, IFilter inputFilter) { - return await RunActionAsync(new ListResults(), inputFilter, (result, backendManager, filter) => - new Operation.ListControlFilesHandler(m_options, result) - .RunAsync(backendManager, filterstrings, filter) + return await RunActionAsync(new ListResults(), filterstrings?.ToArray(), inputFilter, false, static config => + new Operation.ListControlFilesHandler(config.Options, config.Result) + .RunAsync(config.BackendManager, config.Paths, config.Filter) ).ConfigureAwait(false); } /// public async Task ListRemoteAsync() { - return await RunActionAsync(new ListRemoteResults(), async (result, backendManager, _) => + return await RunActionAsync(new ListRemoteResults(), null, null, false, static async config => { - using var tf = File.Exists(m_options.Dbpath) ? null : new TempFile(); - await using var db = await LocalDatabase.CreateLocalDatabaseAsync(((string)tf) ?? m_options.Dbpath, "list-remote", true, null, result.TaskControl.ProgressToken).ConfigureAwait(false); - result.SetResult(await backendManager.ListAsync(CancellationToken.None).ConfigureAwait(false)); + using var tf = File.Exists(config.Options.Dbpath) ? null : new TempFile(); + await using var db = await LocalDatabase.CreateLocalDatabaseAsync(((string)tf) ?? config.Options.Dbpath, "list-remote", true, null, config.Result.TaskControl.ProgressToken).ConfigureAwait(false); + config.Result.SetResult(await config.BackendManager.ListAsync(CancellationToken.None).ConfigureAwait(false)); }).ConfigureAwait(false); } /// public async Task DeleteAllRemoteFilesAsync() { - return await RunActionAsync(new ListRemoteResults(), async (result, backendManager, _) => + return await RunActionAsync(new ListRemoteResults(), null, null, false, static async config => { - result.OperationProgressUpdater.UpdatePhase(OperationPhase.Delete_Listing); + config.Result.OperationProgressUpdater.UpdatePhase(OperationPhase.Delete_Listing); { // Only delete files that match the expected pattern and prefix - var list = (await backendManager.ListAsync(result.TaskControl.ProgressToken).ConfigureAwait(false)) + var list = (await config.BackendManager.ListAsync(config.Result.TaskControl.ProgressToken).ConfigureAwait(false)) .Select(x => Volumes.VolumeBase.ParseFilename(x)) .Where(x => x != null) - .Where(x => x.Prefix == m_options.Prefix) + .Where(x => x.Prefix == config.Options.Prefix) .ToList(); // If the local database is available, we will use it to avoid deleting unrelated files // from the backend. Otherwise, we may accidentally delete non-Duplicati files, or // files from a different Duplicati configuration that points to the same backend location // and uses the same prefix (see issues #2678, #3845, and #4244). - if (File.Exists(m_options.Dbpath)) + if (File.Exists(config.Options.Dbpath)) { - await using LocalDatabase db = await LocalDatabase.CreateLocalDatabaseAsync(m_options.Dbpath, "list-remote", true, null, result.TaskControl.ProgressToken).ConfigureAwait(false); - var dbRemoteVolumes = db.GetRemoteVolumesAsync(result.TaskControl.ProgressToken); + await using LocalDatabase db = await LocalDatabase.CreateLocalDatabaseAsync(config.Options.Dbpath, "list-remote", true, null, config.Result.TaskControl.ProgressToken).ConfigureAwait(false); + var dbRemoteVolumes = db.GetRemoteVolumesAsync(config.Result.TaskControl.ProgressToken); var dbRemoteFiles = await dbRemoteVolumes .Select(x => x.Name) - .ToHashSetAsync(cancellationToken: result.TaskControl.ProgressToken) + .ToHashSetAsync(cancellationToken: config.Result.TaskControl.ProgressToken) .ConfigureAwait(false); list = list.Where(x => dbRemoteFiles.Contains(x.File.Name) ).ToList(); } - result.OperationProgressUpdater.UpdatePhase(OperationPhase.Delete_Deleting); - result.OperationProgressUpdater.UpdateProgress(0); + config.Result.OperationProgressUpdater.UpdatePhase(OperationPhase.Delete_Deleting); + config.Result.OperationProgressUpdater.UpdateProgress(0); for (var i = 0; i < list.Count; i++) { try { - if (m_options.Dryrun) + if (config.Options.Dryrun) { Logging.Log.WriteDryrunMessage(LOGTAG, "WouldDeleteFile", "Would delete file: {0}", list[i].File.Name); } else { - await backendManager.DeleteAsync(list[i].File.Name, list[i].File.Size, true, result.TaskControl.ProgressToken).ConfigureAwait(false); + await config.BackendManager.DeleteAsync(list[i].File.Name, list[i].File.Size, true, config.Result.TaskControl.ProgressToken).ConfigureAwait(false); } } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "DeleteFilesetError", ex, "Failed to delete remote file: {0}", list[i].File.Name); } - result.OperationProgressUpdater.UpdateProgress((float)i / list.Count); + config.Result.OperationProgressUpdater.UpdateProgress((float)i / list.Count); } - result.OperationProgressUpdater.UpdateProgress(1); + config.Result.OperationProgressUpdater.UpdateProgress(1); } }).ConfigureAwait(false); } @@ -334,39 +334,38 @@ namespace Duplicati.Library.Main { CheckAutoVacuumInterval(); - return await RunActionAsync(new CompactResults(), (result, backendManager, _) => - new Operation.CompactHandler(m_options, result) - .RunAsync(backendManager) + return await RunActionAsync(new CompactResults(), null, null, false, static config => + new Operation.CompactHandler(config.Options, config.Result) + .RunAsync(config.BackendManager) ).ConfigureAwait(false); } /// public async Task SetLocksAsync() { - return await RunActionAsync(new SetLockResults(), (result, backendManager, _) => - new Operation.SetLocksHandler(m_options, result) - .RunAsync(backendManager) + return await RunActionAsync(new SetLockResults(), null, null, false, static config => + new Operation.SetLocksHandler(config.Options, config.Result) + .RunAsync(config.BackendManager) ).ConfigureAwait(false); } /// public async Task ReadLockInfoAsync() { - return await RunActionAsync(new ReadLockInfoResults(), (result, backendManager, _) => - new Operation.ReadLockInfoHandler(m_options, result) - .RunAsync(backendManager) + return await RunActionAsync(new ReadLockInfoResults(), null, null, false, static config => + new Operation.ReadLockInfoHandler(config.Options, config.Result) + .RunAsync(config.BackendManager) ).ConfigureAwait(false); } /// public async Task UpdateDatabaseWithVersionsAsync(IFilter inputFilter = null) { - var filelistfilter = Operation.RestoreHandler.GetNumberedFilelistFilterDelegate(m_options.Time, m_options.Version, singleTimeMatch: true); - - return await RunActionAsync(new RecreateDatabaseResults(), inputFilter, async (result, backendManager, filter) => + return await RunActionAsync(new RecreateDatabaseResults(), null, inputFilter, false, static async config => { - using (var h = new Operation.RecreateDatabaseHandler(m_options, result)) - await h.RunUpdateAsync(backendManager, filter, filelistfilter, null) + var filelistfilter = Operation.RestoreHandler.GetNumberedFilelistFilterDelegate(config.Options.Time, config.Options.Version, singleTimeMatch: true); + using (var h = new Operation.RecreateDatabaseHandler(config.Options, config.Result)) + await h.RunUpdateAsync(config.BackendManager, config.Filter, filelistfilter, null) .ConfigureAwait(false); }).ConfigureAwait(false); } @@ -374,30 +373,27 @@ namespace Duplicati.Library.Main /// public async Task CreateLogDatabaseAsync(string targetpath) { - var t = new string[] { targetpath }; - - return await RunActionAsync(new CreateLogDatabaseResults(), t, async (result, backendManager, _) => - await new Operation.CreateBugReportHandler(t[0], m_options, result).RunAsync() + return await RunActionAsync(new CreateLogDatabaseResults(), [targetpath], null, false, static async config => + await new Operation.CreateBugReportHandler(config.Paths[0], config.Options, config.Result).RunAsync() ).ConfigureAwait(false); } /// public async Task ListChangesAsync(string baseVersion, string targetVersion, IEnumerable filterstrings = null, IFilter inputFilter = null, Action>> callback = null) { - var t = new string[] { baseVersion, targetVersion }; - return await RunActionAsync(new ListChangesResults(), t, inputFilter, async (result, backendManager, filter) => - await new Operation.ListChangesHandler(m_options, result) - .RunAsync(t[0], t[1], backendManager, filterstrings, filter, callback) + return await RunActionAsync(new ListChangesResults(), [baseVersion, targetVersion], inputFilter, new { filterstrings, callback }, async static config => + await new Operation.ListChangesHandler(config.Options, config.Result) + .RunAsync(config.Paths[0], config.Paths[1], config.BackendManager, config.Context.filterstrings, config.Filter, config.Context.callback) ).ConfigureAwait(false); } /// public async Task ListAffectedAsync(List args, Action callback = null) { - return await RunActionAsync(new ListAffectedResults(), (result, backendManager, _) => - new Operation.ListAffected(m_options, result) - .RunAsync(args, callback) + return await RunActionAsync(new ListAffectedResults(), args?.ToArray(), null, new { callback }, static config => + new Operation.ListAffected(config.Options, config.Result) + .RunAsync(config.Paths, config.Context.callback) ).ConfigureAwait(false); } @@ -407,9 +403,9 @@ namespace Duplicati.Library.Main if (!m_options.RawOptions.ContainsKey("full-remote-verification")) m_options.RawOptions["full-remote-verification"] = "true"; - return await RunActionAsync(new TestResults(), (result, backendManager, _) => - new Operation.TestHandler(m_options, result) - .RunAsync(samples, backendManager) + return await RunActionAsync(new TestResults(), null, null, new { samples }, static config => + new Operation.TestHandler(config.Options, config.Result) + .RunAsync(config.Context.samples, config.BackendManager) ).ConfigureAwait(false); } @@ -423,9 +419,9 @@ namespace Duplicati.Library.Main var filtertag = Logging.Log.LogTagFromType(typeof(Operation.Backup.FileEnumerationProcess)); using (Logging.Log.StartScope(m_messageSink.WriteMessage, x => x.FilterTag.Contains(filtertag))) { - return await RunActionAsync(new TestFilterResults(), paths, inputFilter, (result, backendManager, filter) => - new Operation.TestFilterHandler(m_options, result) - .RunAsync(ExpandInputSources(paths, filter), filter) + return await RunActionAsync(new TestFilterResults(), paths, inputFilter, false, static config => + new Operation.TestFilterHandler(config.Options, config.Result) + .RunAsync(ExpandInputSources(config.Paths, config.Filter, config.Options), config.Filter) ).ConfigureAwait(false); } } @@ -433,35 +429,35 @@ namespace Duplicati.Library.Main /// public async Task SystemInfoAsync() { - return await RunActionAsync(new SystemInfoResults(), (result, backendManager, _) => - Operation.SystemInfoHandler.RunAsync(result) + return await RunActionAsync(new SystemInfoResults(), null, null, false, static config => + Operation.SystemInfoHandler.RunAsync(config.Result) ).ConfigureAwait(false); } /// public async Task PurgeFilesAsync(IFilter inputFilter) { - return await RunActionAsync(new PurgeFilesResults(), inputFilter, (result, backendManager, filter) => - new Operation.PurgeFilesHandler(m_options, result) - .RunAsync(backendManager, filter) + return await RunActionAsync(new PurgeFilesResults(), null, inputFilter, false, static config => + new Operation.PurgeFilesHandler(config.Options, config.Result) + .RunAsync(config.BackendManager, config.Filter) ).ConfigureAwait(false); } /// public async Task ListBrokenFilesAsync(IFilter inputFilter, Func callbackhandler = null) { - return await RunActionAsync(new ListBrokenFilesResults(), inputFilter, (result, backendManager, filter) => - new Operation.ListBrokenFilesHandler(m_options, result) - .RunAsync(backendManager, filter, callbackhandler) + return await RunActionAsync(new ListBrokenFilesResults(), null, inputFilter, new { callbackhandler }, static config => + new Operation.ListBrokenFilesHandler(config.Options, config.Result) + .RunAsync(config.BackendManager, config.Filter, config.Context.callbackhandler) ).ConfigureAwait(false); } /// public async Task PurgeBrokenFilesAsync(IFilter inputFilter) { - return await RunActionAsync(new PurgeBrokenFilesResults(), inputFilter, (result, backendManager, filter) => - new Operation.PurgeBrokenFilesHandler(m_options, result) - .RunAsync(backendManager, filter) + return await RunActionAsync(new PurgeBrokenFilesResults(), null, inputFilter, false, static config => + new Operation.PurgeBrokenFilesHandler(config.Options, config.Result) + .RunAsync(config.BackendManager, config.Filter) ).ConfigureAwait(false); } @@ -488,11 +484,11 @@ namespace Duplicati.Library.Main var filtertag = Logging.Log.LogTagFromType(DynamicLoader.GenericLoader.GetModule(sendMailModuleKey).GetType()); using (Logging.Log.StartScope(m_messageSink.WriteMessage, x => x.FilterTag.Contains(filtertag))) { - return await RunActionAsync(new SendMailResults(), (result, backendManager, _) => + return await RunActionAsync(new SendMailResults(), null, null, false, static config => Task.Run(() => { - result.Lines = new string[0]; - System.Threading.Thread.Sleep(5); + config.Result.Lines = []; + Thread.Sleep(5); }) ).ConfigureAwait(false); } @@ -501,36 +497,23 @@ namespace Duplicati.Library.Main /// public async Task VacuumAsync() { - return await RunActionAsync(new VacuumResults(), (result, backendManager, _) => - new Operation.VacuumHandler(m_options, result) + return await RunActionAsync(new VacuumResults(), null, null, false, static config => + new Operation.VacuumHandler(config.Options, config.Result) .RunAsync() ).ConfigureAwait(false); } - private Task RunActionAsync(T result, Func method) - where T : ISetCommonOptions, ITaskControlProvider, Logging.ILogDestination, IBasicResults, IBackendWriterProvider - { - var tmp = new string[0]; - IFilter tempfilter = null; - return RunActionAsync(result, tmp, tempfilter, method); - } + private sealed record RunActionData( + TRes Result, + TCon Context, + string BackendUrl, + string[] Paths, + Options Options, + IFilter Filter, + IBackendManager BackendManager); - private Task RunActionAsync(T result, string[] paths, Func method) - where T : ISetCommonOptions, ITaskControlProvider, Logging.ILogDestination, IBasicResults, IBackendWriterProvider - { - IFilter tempfilter = null; - return RunActionAsync(result, paths, tempfilter, method); - } - - private Task RunActionAsync(T result, IFilter filter, Func method) - where T : ISetCommonOptions, ITaskControlProvider, Logging.ILogDestination, IBasicResults, IBackendWriterProvider - { - var tmp = new string[0]; - return RunActionAsync(result, tmp, filter, method); - } - - private async Task RunActionAsync(T result, string[] paths, IFilter filter, Func method) - where T : ISetCommonOptions, ITaskControlProvider, Logging.ILogDestination, IBasicResults, IBackendWriterProvider + private async Task RunActionAsync(TRes result, string[] paths, IFilter filter, TCon context, Func, Task> method) + where TRes : ISetCommonOptions, ITaskControlProvider, Logging.ILogDestination, IBasicResults, IBackendWriterProvider { OnOperationStarted?.Invoke(result); var resultSetter = result as ISetCommonOptions; @@ -562,7 +545,8 @@ namespace Duplicati.Library.Main { backend.UpdateThrottleValues(m_options.MaxUploadPrSecond, m_options.MaxDownloadPrSecond); m_currentBackendManager = backend; - await method(result, backend, filter).ConfigureAwait(false); + var config = new RunActionData(result, context, m_backendUrl, paths, m_options, filter, backend); + await method(config).ConfigureAwait(false); } finally { @@ -1067,8 +1051,11 @@ namespace Duplicati.Library.Main /// Helper method that expands the users chosen source input paths, /// and removes duplicate paths /// + /// The input sources. + /// The filter. + /// The options. /// The expanded and filtered sources. - private string[] ExpandInputSources(string[] inputsources, IFilter filter) + private static string[] ExpandInputSources(string[] inputsources, IFilter filter, Options options) { if (inputsources == null || inputsources.Length == 0) throw new UserInformationException(Strings.Controller.NoSourceFoldersError, "NoSourceFolders"); @@ -1116,14 +1103,14 @@ namespace Duplicati.Library.Main else { // If we aren't allow to have missing sources, throw an exception indicating we couldn't find a drive where this volume is mounted - if (!m_options.AllowMissingSource) + if (!options.AllowMissingSource) throw new UserInformationException(Strings.Controller.SourceVolumeNameNotFoundError(inputsource, volumeGuid), "MissingSourceFolder"); } } else { // If we aren't allow to have missing sources, throw an exception indicating we couldn't find this volume - if (!m_options.AllowMissingSource) + if (!options.AllowMissingSource) throw new UserInformationException(Strings.Controller.SourceVolumeNameInvalidError(inputsource), "SourceVolumeNameInvalid"); } } @@ -1175,7 +1162,7 @@ namespace Duplicati.Library.Main // If the directory exists, but is empty, and we are not allowed to have empty sources, throw an error try { - if (m_options.PreventEmptySource && di.Exists && !di.EnumerateFileSystemInfos().Any()) + if (options.PreventEmptySource && di.Exists && !di.EnumerateFileSystemInfos().Any()) throw new UserInformationException(Strings.Controller.SourceFolderEmptyError(inputsource), "SourceFolderEmpty"); } catch (UnauthorizedAccessException ex) @@ -1206,7 +1193,7 @@ namespace Duplicati.Library.Main } // If no paths were found, and we aren't allowed to have missing sources, throw an error - if (!foundAnyPaths && !m_options.AllowMissingSource) + if (!foundAnyPaths && !options.AllowMissingSource) { if (unauthorized) throw new UserInformationException(Strings.Controller.SourceUnauthorizedError(inputsource), "SourceUnauthorized"); diff --git a/Duplicati/Library/Main/Operation/ListAffected.cs b/Duplicati/Library/Main/Operation/ListAffected.cs index d63c45b01..a308de2d0 100644 --- a/Duplicati/Library/Main/Operation/ListAffected.cs +++ b/Duplicati/Library/Main/Operation/ListAffected.cs @@ -39,7 +39,7 @@ namespace Duplicati.Library.Main.Operation m_result = result; } - public async Task RunAsync(List args, Action callback = null) + public async Task RunAsync(string[] args, Action callback = null) { if (!File.Exists(m_options.Dbpath)) throw new UserInformationException(string.Format("Database file does not exist: {0}", m_options.Dbpath), "DatabaseDoesNotExist"); From 49f08bdbbed52ae52dfb73c353d7447ab69b8b3c Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 12 Jun 2026 18:08:25 +0200 Subject: [PATCH 7/9] Fixed an issue with HyperV parsing failing if the filter string was null --- .../Library/Modules/Builtin/HyperVOptions.cs | 2 +- Duplicati/UnitTest/TestHyperVParser.cs | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Duplicati/Library/Modules/Builtin/HyperVOptions.cs b/Duplicati/Library/Modules/Builtin/HyperVOptions.cs index 50b27dcc6..212fefe5a 100644 --- a/Duplicati/Library/Modules/Builtin/HyperVOptions.cs +++ b/Duplicati/Library/Modules/Builtin/HyperVOptions.cs @@ -258,7 +258,7 @@ namespace Duplicati.Library.Modules.Builtin .ToList(); var filterhandler = pathFilters.Select(x => x.Filter) - .Concat(filter.Split(new[] { System.IO.Path.PathSeparator }, StringSplitOptions.RemoveEmptyEntries)) + .Concat((filter ?? "").Split(new[] { System.IO.Path.PathSeparator }, StringSplitOptions.RemoveEmptyEntries)) .Where(x => !string.IsNullOrWhiteSpace(x)) .Select(x => Utility.FilterExpression.StringToIFilter(x)) .Append(new Utility.FilterExpression()) diff --git a/Duplicati/UnitTest/TestHyperVParser.cs b/Duplicati/UnitTest/TestHyperVParser.cs index 8799946e4..96eb6512f 100644 --- a/Duplicati/UnitTest/TestHyperVParser.cs +++ b/Duplicati/UnitTest/TestHyperVParser.cs @@ -393,5 +393,29 @@ namespace Duplicati.UnitTest Assert.That(paths, Does.Contain(gWanted.DataPaths.First()), "wanted VM missing"); Assert.That(paths, Does.Not.Contain(gOther.DataPaths.First()), "unexpected VM leaked in"); } + + [Test] + public void Handles_null_filter_gracefully() + { + if (!OperatingSystem.IsWindows()) + return; // Hyper-V is only available on Windows + + var g = new HyperVGuest("VM", Guid.NewGuid(), + [@$"C:{DS}VMs{DS}VM{DS}disk.vhdx"]); + + var paths = new[] { "%HYPERV%" }; + string filter = null; + var options = new Dictionary(StringComparer.OrdinalIgnoreCase) + { ["snapshot-policy"] = "required" }; + + var util = new MockHyperVUtility { Guests = new List { g } }; + + using var _ = Duplicati.Library.Logging.Log.StartScope(new LogSink()); + new Duplicati.Library.Modules.Builtin.HyperVOptions() + .RealParseSourcePaths(ref paths, ref filter, options, util); + + Assert.That(paths, Does.Contain(g.DataPaths.First()), + "Guest data paths should be included when filter is null"); + } } } \ No newline at end of file From 4f26f77f6c5abd6df318fc5037a73005d2e65d65 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 12 Jun 2026 19:27:15 +0200 Subject: [PATCH 8/9] Fixed failing test --- Duplicati/UnitTest/RunScriptTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Duplicati/UnitTest/RunScriptTests.cs b/Duplicati/UnitTest/RunScriptTests.cs index 6a43a7ea0..196bc4f1b 100644 --- a/Duplicati/UnitTest/RunScriptTests.cs +++ b/Duplicati/UnitTest/RunScriptTests.cs @@ -415,7 +415,7 @@ namespace Duplicati.UnitTest var customCommandsBefore = new List { - $"echo --filter=\"{expectedFilter}\"" + $"echo --filter=\"+{expectedFilter}\"" }; var customCommandsAfter = new List(); @@ -451,6 +451,7 @@ namespace Duplicati.UnitTest } var linesAfter = File.ReadAllLines(parsedResultFileAfter); + Console.WriteLine($"linesAfter: {string.Join(",", linesAfter)}"); Assert.AreEqual(1, linesAfter.Length); Assert.IsTrue(linesAfter[0].Contains(expectedFilter, StringComparison.OrdinalIgnoreCase)); From d00166f79120a11d30e91e87215d589c133bc96b Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 12 Jun 2026 19:40:32 +0200 Subject: [PATCH 9/9] Attempt to fix test issues on Windows --- Duplicati/UnitTest/RecoveryToolPathTraversalTests.cs | 10 ++++++++-- Duplicati/UnitTest/RunScriptTests.cs | 7 ++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Duplicati/UnitTest/RecoveryToolPathTraversalTests.cs b/Duplicati/UnitTest/RecoveryToolPathTraversalTests.cs index fd492ee89..a397cf87e 100644 --- a/Duplicati/UnitTest/RecoveryToolPathTraversalTests.cs +++ b/Duplicati/UnitTest/RecoveryToolPathTraversalTests.cs @@ -89,8 +89,14 @@ namespace Duplicati.UnitTest var filelistPath = Directory.GetFiles(tempDir, "*filelist*").FirstOrDefault(); var json = File.ReadAllText(filelistPath); - // Replace ONLY ONE file with traversal, keep the other - json = json.Replace("subdir/file2.txt", "subdir/../../../../../../../../../../../tmp/evil.txt"); + // Replace ONLY ONE file with traversal, keep the other. + var sep = Path.DirectorySeparatorChar; + // Deal with JSON escape of backslash + var jsonSep = sep == '\\' ? "\\\\" : sep.ToString(); + var traversal = string.Concat(Enumerable.Repeat(".." + jsonSep, 11)); + json = json.Replace( + $"subdir{jsonSep}file2.txt", + $"subdir{jsonSep}{traversal}tmp{jsonSep}evil.txt"); File.WriteAllText(filelistPath, json); ZipFile.CreateFromDirectory(tempDir, dlistPath); diff --git a/Duplicati/UnitTest/RunScriptTests.cs b/Duplicati/UnitTest/RunScriptTests.cs index 196bc4f1b..5807eff39 100644 --- a/Duplicati/UnitTest/RunScriptTests.cs +++ b/Duplicati/UnitTest/RunScriptTests.cs @@ -451,13 +451,14 @@ namespace Duplicati.UnitTest } var linesAfter = File.ReadAllLines(parsedResultFileAfter); - Console.WriteLine($"linesAfter: {string.Join(",", linesAfter)}"); Assert.AreEqual(1, linesAfter.Length); - Assert.IsTrue(linesAfter[0].Contains(expectedFilter, StringComparison.OrdinalIgnoreCase)); + Assert.IsTrue(linesAfter[0].Contains(expectedFilter, StringComparison.OrdinalIgnoreCase), + $"Expected the after-script filter output to contain '{expectedFilter}', but got: '{linesAfter[0]}'"); var linesPostBackup = File.ReadAllLines(parsedResultFilePostBackup); Assert.AreEqual(1, linesPostBackup.Length); - Assert.IsTrue(linesPostBackup[0].Contains(expectedFilter, StringComparison.OrdinalIgnoreCase)); + Assert.IsTrue(linesPostBackup[0].Contains(expectedFilter, StringComparison.OrdinalIgnoreCase), + $"Expected the post-backup-script filter output to contain '{expectedFilter}', but got: '{linesPostBackup[0]}'"); } [Test]