// Copyright (C) 2024, 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.Common; using Duplicati.Library.Interface; using Duplicati.Library.Main; using NUnit.Framework; 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 void RunScriptAfter(int exitCode) { const string expectedMessage = "Hello"; string expectedFile = Path.Combine(this.RESTOREFOLDER, "hello.txt"); List customCommands = new List { $"echo {expectedMessage}>\"{expectedFile}\"" }; Dictionary options = this.TestOptions; options["run-script-after"] = CreateScript(exitCode, null, null, 0, customCommands); using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { IBackupResults backupResults = c.Backup(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 void RunScriptBefore() { var blocksize = 10 * 1024; var options = TestOptions; 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)) { var res = c.Backup(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"); System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(0); res = c.Backup(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?"); System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(1); res = c.Backup(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?"); System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(2); res = c.Backup(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?"); System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(3); res = c.Backup(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?"); System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(4); res = c.Backup(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}) { System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(exitCode); res = c.Backup(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}?"); } System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(2, "TEST WARNING MESSAGE"); res = c.Backup(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"); System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(3, "TEST WARNING MESSAGE"); res = c.Backup(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"); System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(4, "TEST ERROR MESSAGE"); res = c.Backup(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"); System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(5, "TEST ERROR MESSAGE"); res = c.Backup(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"); System.Threading.Thread.Sleep(PAUSE_TIME); options["run-script-before"] = CreateScript(0, sleeptime: 10); res = c.Backup(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?"); } } [Test] [Category("Border")] [TestCase(0)] [TestCase(1)] [TestCase(2)] [TestCase(3)] [TestCase(4)] [TestCase(5)] [TestCase(6)] public void RunScriptParsedResult(int exitCode) { string parsedResultFile = Path.Combine(this.RESTOREFOLDER, "result.txt"); List customCommands = new List(); if (OperatingSystem.IsWindows()) { customCommands.Add($"echo %DUPLICATI__PARSED_RESULT%>\"{parsedResultFile}\""); } else { customCommands.Add($"echo $DUPLICATI__PARSED_RESULT>\"{parsedResultFile}\""); } Dictionary options = this.TestOptions; options["run-script-before"] = this.CreateScript(exitCode); options["run-script-after"] = this.CreateScript(0, null, null, 0, customCommands); using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { IBackupResults backupResults = c.Backup(new[] {this.DATAFOLDER}); bool expectBackup; string expectedParsedResult; switch (exitCode) { case 0: // OK, run operation Assert.AreEqual(0, backupResults.Errors.Count()); Assert.AreEqual(0, backupResults.Warnings.Count()); expectBackup = true; expectedParsedResult = ParsedResultType.Success.ToString(); break; case 1: // OK, don't run operation Assert.AreEqual(0, backupResults.Errors.Count()); Assert.AreEqual(0, backupResults.Warnings.Count()); Assert.IsTrue(backupResults.Interrupted); expectBackup = false; expectedParsedResult = ParsedResultType.Success.ToString(); break; case 2: // Warning, run operation Assert.AreEqual(0, backupResults.Errors.Count()); Assert.AreEqual(1, backupResults.Warnings.Count()); Assert.IsFalse(backupResults.Interrupted); expectBackup = true; expectedParsedResult = ParsedResultType.Warning.ToString(); break; case 3: // Warning, don't run operation Assert.AreEqual(0, backupResults.Errors.Count()); Assert.AreEqual(1, backupResults.Warnings.Count()); Assert.IsTrue(backupResults.Interrupted); expectBackup = false; expectedParsedResult = ParsedResultType.Warning.ToString(); break; case 4: // Error, run operation Assert.AreEqual(1, backupResults.Errors.Count()); Assert.AreEqual(0, backupResults.Warnings.Count()); Assert.IsFalse(backupResults.Interrupted); expectBackup = true; expectedParsedResult = ParsedResultType.Error.ToString(); break; default: // Error don't run operation Assert.AreEqual(1, backupResults.Errors.Count()); Assert.AreEqual(0, backupResults.Warnings.Count()); Assert.IsTrue(backupResults.Interrupted); expectBackup = false; expectedParsedResult = ParsedResultType.Error.ToString(); break; } IEnumerable 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()); } string[] lines = File.ReadAllLines(parsedResultFile); Assert.AreEqual(1, lines.Length); Assert.AreEqual(expectedParsedResult, lines[0]); } } [Test] [Category("Border")] public void CustomRemoteURL() { string customTargetFolder = Path.Combine(this.TARGETFOLDER, "destination"); Directory.CreateDirectory(customTargetFolder); List customCommands = new List { $"echo --remoteurl = \"{customTargetFolder}\"" }; Dictionary options = this.TestOptions; options["run-script-before"] = CreateScript(0, null, null, 0, customCommands); using (Controller c = new Library.Main.Controller("file://" + this.TARGETFOLDER, options, null)) { IBackupResults backupResults = c.Backup(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()); } 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(); commands.Add("#!/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; } } } }