// 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 System; using System.Collections.Generic; using System.IO; using System.Linq; using Duplicati.Library.Interface; using Duplicati.Library.Main; using NUnit.Framework; using System.Threading.Tasks; using Assert = NUnit.Framework.Legacy.ClassicAssert; namespace Duplicati.UnitTest { public class RunScriptTests : BasicSetupHelper { [Test] [Category("Border")] [TestCase(0)] [TestCase(1)] [TestCase(2)] [TestCase(3)] [TestCase(4)] [TestCase(5)] [TestCase(6)] public async Task RunScriptAfterAsync(int exitCode) { const string expectedMessage = "Hello"; var expectedFile = Path.Combine(this.RESTOREFOLDER, "hello.txt"); var customCommands = new List { $"echo {expectedMessage}>\"{expectedFile}\"" }; var options = new Dictionary(this.TestOptions); options["run-script-after"] = CreateScript(exitCode, null, null, 0, customCommands); using (var c = new Controller("file://" + this.TARGETFOLDER, options, null)) { var backupResults = await c.BackupAsync(new[] { this.DATAFOLDER }); switch (exitCode) { case 0: case 1: Assert.AreEqual(0, backupResults.Errors.Count()); Assert.AreEqual(0, backupResults.Warnings.Count()); break; case 2: case 3: Assert.AreEqual(0, backupResults.Errors.Count()); Assert.AreEqual(1, backupResults.Warnings.Count()); break; default: Assert.AreEqual(1, backupResults.Errors.Count()); Assert.AreEqual(0, backupResults.Warnings.Count()); break; } // run script after does not set interrupted flag Assert.IsFalse(backupResults.Interrupted); string[] targetEntries = Directory.EnumerateFileSystemEntries(this.RESTOREFOLDER).ToArray(); Assert.AreEqual(1, targetEntries.Length); Assert.AreEqual(expectedFile, targetEntries[0]); string[] lines = File.ReadAllLines(expectedFile); Assert.AreEqual(1, lines.Length); Assert.AreEqual(expectedMessage, lines[0]); } } [Test] [Category("Border")] public async Task RunScriptBeforeAsync() { var blocksize = 10 * 1024; var options = new Dictionary(TestOptions); options["blocksize"] = blocksize.ToString() + "b"; options["run-script-timeout"] = "5s"; BorderTests.WriteTestFilesToFolder(DATAFOLDER, blocksize, 0); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { var res = await c.BackupAsync(new string[] { DATAFOLDER }); Assert.AreEqual(0, res.Errors.Count()); Assert.AreEqual(0, res.Warnings.Count()); if (res.ParsedResult != ParsedResultType.Success) throw new Exception("Unexpected result from base backup"); } options["run-script-before"] = CreateScript(0); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { var res = await c.BackupAsync(new string[] { DATAFOLDER }); if (res.ParsedResult != ParsedResultType.Success) throw new Exception("Unexpected result from backup with return code 0"); if (res.ExaminedFiles <= 0) throw new Exception("Backup did not examine any files for code 0?"); } options["run-script-before"] = CreateScript(1); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { var res = await c.BackupAsync(new string[] { DATAFOLDER }); if (res.ParsedResult != ParsedResultType.Success) throw new Exception("Unexpected result from backup with return code 1"); if (res.ExaminedFiles > 0) throw new Exception("Backup did examine files for code 1?"); } options["run-script-before"] = CreateScript(2); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { var res = await c.BackupAsync(new string[] { DATAFOLDER }); if (res.ParsedResult != ParsedResultType.Warning) throw new Exception("Unexpected result from backup with return code 2"); if (res.ExaminedFiles <= 0) throw new Exception("Backup did not examine any files for code 2?"); } options["run-script-before"] = CreateScript(3); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { var res = await c.BackupAsync(new string[] { DATAFOLDER }); if (res.ParsedResult != ParsedResultType.Warning) throw new Exception("Unexpected result from backup with return code 3"); if (res.ExaminedFiles > 0) throw new Exception("Backup did examine files for code 3?"); } options["run-script-before"] = CreateScript(4); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { var res = await c.BackupAsync(new string[] { DATAFOLDER }); if (res.ParsedResult != ParsedResultType.Error) throw new Exception("Unexpected result from backup with return code 4"); if (res.ExaminedFiles <= 0) throw new Exception("Backup did not examine any files for code 4?"); } foreach (int exitCode in new[] { 5, 6, 10, 99 }) { options["run-script-before"] = CreateScript(exitCode); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { var res = await c.BackupAsync(new string[] { DATAFOLDER }); if (res.ParsedResult != ParsedResultType.Error) throw new Exception($"Unexpected result from backup with return code {exitCode}"); if (res.ExaminedFiles > 0) throw new Exception($"Backup did examine files for code {exitCode}?"); } } options["run-script-before"] = CreateScript(2, "TEST WARNING MESSAGE"); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { var res = await c.BackupAsync(new string[] { DATAFOLDER }); if (res.ParsedResult != ParsedResultType.Warning) throw new Exception("Unexpected result from backup with return code 2"); if (res.ExaminedFiles <= 0) throw new Exception("Backup did examine files for code 2?"); if (!res.Warnings.Any(x => x.IndexOf("TEST WARNING MESSAGE", StringComparison.Ordinal) >= 0)) throw new Exception("Found no warning message in output for code 2"); } options["run-script-before"] = CreateScript(3, "TEST WARNING MESSAGE"); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { var res = await c.BackupAsync(new string[] { DATAFOLDER }); if (res.ParsedResult != ParsedResultType.Warning) throw new Exception("Unexpected result from backup with return code 3"); if (res.ExaminedFiles > 0) throw new Exception("Backup did examine files for code 3?"); if (!res.Warnings.Any(x => x.IndexOf("TEST WARNING MESSAGE", StringComparison.Ordinal) >= 0)) throw new Exception("Found no warning message in output for code 3"); } options["run-script-before"] = CreateScript(4, "TEST ERROR MESSAGE"); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { var res = await c.BackupAsync(new string[] { DATAFOLDER }); if (res.ParsedResult != ParsedResultType.Error) throw new Exception("Unexpected result from backup with return code 4"); if (res.ExaminedFiles <= 0) throw new Exception("Backup did examine files for code 4?"); if (!res.Errors.Any(x => x.IndexOf("TEST ERROR MESSAGE", StringComparison.Ordinal) >= 0)) throw new Exception("Found no error message in output for code 4"); } options["run-script-before"] = CreateScript(5, "TEST ERROR MESSAGE"); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { var res = await c.BackupAsync(new string[] { DATAFOLDER }); if (res.ParsedResult != ParsedResultType.Error) throw new Exception("Unexpected result from backup with return code 5"); if (res.ExaminedFiles > 0) throw new Exception("Backup did examine files for code 5?"); if (!res.Errors.Any(x => x.IndexOf("TEST ERROR MESSAGE", StringComparison.Ordinal) >= 0)) throw new Exception("Found no error message in output for code 5"); } options["run-script-before"] = CreateScript(0, sleeptime: 10); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, options, null)) { var res = await c.BackupAsync(new string[] { DATAFOLDER }); if (res.ParsedResult != ParsedResultType.Warning) throw new Exception("Unexpected result from backup with timeout script"); if (res.ExaminedFiles <= 0) throw new Exception("Backup did not examine any files after timeout?"); } } private static void AssertWithMessage(IEnumerable messages, int count) { if (messages.Count() != count) { string message = string.Join(Environment.NewLine, messages); Assert.Fail($"Expected {count} messages, but got {messages.Count()}:\n{message}"); } } [Test] [Category("Border")] [TestCase(0)] [TestCase(1)] [TestCase(2)] [TestCase(3)] [TestCase(4)] [TestCase(5)] [TestCase(6)] public async Task RunScriptParsedResultAsync(int exitCode) { var parsedResultFile = Path.Combine(this.RESTOREFOLDER, "result.txt"); var customCommands = new List(); if (OperatingSystem.IsWindows()) { customCommands.Add($"echo %DUPLICATI__PARSED_RESULT%>\"{parsedResultFile}\""); } else { customCommands.Add($"echo $DUPLICATI__PARSED_RESULT>\"{parsedResultFile}\""); } var options = new Dictionary(this.TestOptions); options["run-script-before"] = this.CreateScript(exitCode); options["run-script-after"] = this.CreateScript(0, null, null, 0, customCommands); using (var c = new Controller("file://" + this.TARGETFOLDER, options, null)) { var backupResults = await c.BackupAsync(new[] { this.DATAFOLDER }); bool expectBackup; string expectedParsedResult; switch (exitCode) { case 0: // OK, run operation if (OperatingSystem.IsWindows()) { // Windows reports warnings due to CI test mounted folders AssertWithMessage(backupResults.Errors, 0); } else { TestUtils.AssertResults(backupResults); } expectBackup = true; expectedParsedResult = ParsedResultType.Success.ToString(); break; case 1: // OK, don't run operation TestUtils.AssertResults(backupResults); Assert.IsTrue(backupResults.Interrupted); expectBackup = false; expectedParsedResult = ParsedResultType.Success.ToString(); break; case 2: // Warning, run operation AssertWithMessage(backupResults.Errors, 0); AssertWithMessage(backupResults.Warnings, 1); Assert.IsFalse(backupResults.Interrupted); expectBackup = true; expectedParsedResult = ParsedResultType.Warning.ToString(); break; case 3: // Warning, don't run operation AssertWithMessage(backupResults.Errors, 0); AssertWithMessage(backupResults.Warnings, 1); Assert.IsTrue(backupResults.Interrupted); expectBackup = false; expectedParsedResult = ParsedResultType.Warning.ToString(); break; case 4: // Error, run operation AssertWithMessage(backupResults.Errors, 1); AssertWithMessage(backupResults.Warnings, 0); Assert.IsFalse(backupResults.Interrupted); expectBackup = true; expectedParsedResult = ParsedResultType.Error.ToString(); break; default: // Error don't run operation AssertWithMessage(backupResults.Errors, 1); AssertWithMessage(backupResults.Warnings, 0); Assert.IsTrue(backupResults.Interrupted); expectBackup = false; expectedParsedResult = ParsedResultType.Error.ToString(); break; } var targetEntries = Directory.EnumerateFileSystemEntries(this.TARGETFOLDER); if (expectBackup) { // We expect a dblock, dlist, and dindex file. Assert.AreEqual(3, targetEntries.Count()); } else { Assert.AreEqual(0, targetEntries.Count()); } var lines = File.ReadAllLines(parsedResultFile); Assert.AreEqual(1, lines.Length); Assert.AreEqual(expectedParsedResult, lines[0]); } } [Test] [Category("Border")] public async Task CustomRemoteURLAsync() { var customTargetFolder = Path.Combine(this.TARGETFOLDER, "destination"); Directory.CreateDirectory(customTargetFolder); var customCommands = new List { $"echo --remoteurl = \"{customTargetFolder}\"" }; 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()); } string[] targetEntries = Directory.EnumerateFileSystemEntries(this.TARGETFOLDER).ToArray(); Assert.AreEqual(1, targetEntries.Length); Assert.AreEqual(customTargetFolder, targetEntries[0]); // We expect a dblock, dlist, and dindex file. IEnumerable customTargetEntries = Directory.EnumerateFileSystemEntries(customTargetFolder); 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() { 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.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), $"Expected the post-backup-script filter output to contain '{expectedFilter}', but got: '{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.IsTrue(linesAfter[0].Contains(expectedFilter, StringComparison.OrdinalIgnoreCase)); var linesPostBackup = File.ReadAllLines(parsedResultFilePostBackup); Assert.AreEqual(1, linesPostBackup.Length); 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) { var id = Guid.NewGuid().ToString("N").Substring(0, 6); if (OperatingSystem.IsWindows()) { var commands = customCommands ?? new List(); if (!string.IsNullOrWhiteSpace(stdout)) commands.Add($@"echo {stdout}"); if (!string.IsNullOrWhiteSpace(stderr)) commands.Add($@"echo {stderr} 1>&2"); if (sleeptime > 0) commands.Add($@"sleep {sleeptime}"); commands.Add($"exit {exitcode}"); var filename = Path.GetFullPath(Path.Combine(DATAFOLDER, $"run-script-{id}.bat")); File.WriteAllLines(filename, commands); return filename; } else { var commands = new List() { "#!/bin/sh" }; if (customCommands != null) commands.AddRange(customCommands); if (!string.IsNullOrWhiteSpace(stdout)) commands.Add($@"echo {stdout}"); if (!string.IsNullOrWhiteSpace(stderr)) commands.Add($@"(>&2 echo {stderr})"); if (sleeptime > 0) commands.Add($@"sleep {sleeptime}"); commands.Add($"exit {exitcode}"); var filename = Path.GetFullPath(Path.Combine(DATAFOLDER, $"run-script-{id}.sh")); File.WriteAllLines(filename, commands); System.Diagnostics.Process.Start("chmod", $@"+x ""{filename}""").WaitForExit(); return filename; } } } }