// 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 System.Reflection; using System.Threading; using System.Threading.Tasks; using Duplicati.Library.DynamicLoader; using Duplicati.Library.Interface; using Duplicati.Library.Main; using Duplicati.Library.Main.Operation; using Duplicati.Library.Main.Operation.Restore; using NUnit.Framework; using Assert = NUnit.Framework.Legacy.ClassicAssert; #nullable enable namespace Duplicati.UnitTest { /// /// Tests for the restore hook interface and its /// wiring in / the restore FileProcessor. /// [NonParallelizable] public class RestoreCallbackModuleTests : BasicSetupHelper { /// /// A minimal implementation that records calls /// on the instance, used for the dispatch-helper unit tests. /// private sealed class FakeRestoreCallbackModule : IRestoreCallbackModule, IGenericPriorityModule { public string Key { get; set; } = "fake"; public string DisplayName => "Fake"; public string Description => "Fake restore callback module"; public bool LoadAsDefault => false; public int Priority { get; set; } public IList SupportedCommands => new List(); public List<(string files, long version, DateTime timestamp)> PrepareCalls { get; } = new(); public int BulkStartCalls { get; set; } public List<(long version, string path, DateTime timestamp)> FileRestoredCalls { get; } = new(); public Action>? OnPrepare { get; set; } public Action? OnBulk { get; set; } public Action? OnFile { get; set; } public Task OnPreparePriorityFilesAsync(IList priorityFiles, long version, DateTime backupTimestamp, CancellationToken cancellationToken) { PrepareCalls.Add((string.Join(",", priorityFiles), version, backupTimestamp)); OnPrepare?.Invoke(priorityFiles); return Task.CompletedTask; } public Task OnBulkRestoreStartAsync(CancellationToken cancellationToken) { BulkStartCalls++; OnBulk?.Invoke(); return Task.CompletedTask; } public Task OnFileRestoredAsync(long version, string path, DateTime backupTimestamp, CancellationToken cancellationToken) { FileRestoredCalls.Add((version, path, backupTimestamp)); OnFile?.Invoke(version, path); return Task.CompletedTask; } public void Configure(IDictionary commandlineOptions) { } } /// /// A module that is NOT a restore callback module, used to verify the dispatch /// helpers skip non-matching modules. /// private sealed class NonCallbackModule : IGenericModule { public string Key => "non-callback"; public string DisplayName => "NonCallback"; public string Description => ""; public bool LoadAsDefault => false; public IList SupportedCommands => new List(); public void Configure(IDictionary commandlineOptions) { } } [Test] [Category("RestoreHandler")] public async Task InvokePreparePriorityFilesCallsModulesAndAllowsMutationAsync() { var modA = new FakeRestoreCallbackModule { Key = "a" }; var modB = new FakeRestoreCallbackModule { Key = "b" }; modA.OnPrepare = list => list.Add("from-a"); modB.OnPrepare = list => list.Remove("remove-me"); var priorityFiles = new List { "keep", "remove-me" }; // Mix in a non-callback module to ensure it is skipped. const long expectedVersion = 7; var expectedTimestamp = new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc); await RestoreHandler.InvokePreparePriorityFilesAsync(new IGenericModule[] { new NonCallbackModule(), modA, modB }, priorityFiles, expectedVersion, expectedTimestamp, CancellationToken.None); // Both callback modules were invoked exactly once, in order. Assert.AreEqual(1, modA.PrepareCalls.Count); Assert.AreEqual(1, modB.PrepareCalls.Count); // The version and backup timestamp were forwarded to the modules. Assert.AreEqual(expectedVersion, modA.PrepareCalls[0].version); Assert.AreEqual(expectedTimestamp, modA.PrepareCalls[0].timestamp); Assert.AreEqual(expectedVersion, modB.PrepareCalls[0].version); Assert.AreEqual(expectedTimestamp, modB.PrepareCalls[0].timestamp); // modA saw the original list, modB saw the list after modA mutated it. Assert.AreEqual("keep,remove-me", modA.PrepareCalls[0].files); Assert.AreEqual("keep,remove-me,from-a", modB.PrepareCalls[0].files); // The shared list reflects both mutations. Assert.That(priorityFiles, Is.EqualTo(new[] { "keep", "from-a" })); } [Test] [Category("RestoreHandler")] public async Task InvokePreparePriorityFilesIsolatedFromThrowingModulesAsync() { var throwing = new FakeRestoreCallbackModule { Key = "throw", OnPrepare = _ => throw new InvalidOperationException("boom") }; var after = new FakeRestoreCallbackModule { Key = "after" }; var priorityFiles = new List { "a" }; await RestoreHandler.InvokePreparePriorityFilesAsync(new IGenericModule[] { throwing, after }, priorityFiles, 0, DateTime.MinValue, CancellationToken.None); // The throwing module did not abort dispatch to the next module. Assert.AreEqual(1, after.PrepareCalls.Count); } [Test] [Category("RestoreHandler")] public async Task InvokeBulkRestoreStartCallsAllModulesInOrderAsync() { var order = new List(); var modA = new FakeRestoreCallbackModule { Key = "a", OnBulk = () => order.Add("a") }; var modB = new FakeRestoreCallbackModule { Key = "b", OnBulk = () => order.Add("b") }; await RestoreHandler.InvokeBulkRestoreStartAsync(new IGenericModule[] { new NonCallbackModule(), modA, modB }, CancellationToken.None); Assert.AreEqual(1, modA.BulkStartCalls); Assert.AreEqual(1, modB.BulkStartCalls); Assert.That(order, Is.EqualTo(new[] { "a", "b" })); } [Test] [Category("RestoreHandler")] public async Task InvokeFileRestoredPassesVersionAndPathAsync() { var mod = new FakeRestoreCallbackModule(); const long expectedVersion = 2; var expectedTimestamp = new DateTime(2023, 6, 15, 12, 0, 0, DateTimeKind.Utc); await RestoreHandler.InvokeFileRestoredAsync(new IGenericModule[] { new NonCallbackModule(), mod }, expectedVersion, "/some/path/file.txt", expectedTimestamp, CancellationToken.None); Assert.AreEqual(1, mod.FileRestoredCalls.Count); Assert.AreEqual(expectedVersion, mod.FileRestoredCalls[0].version); Assert.AreEqual("/some/path/file.txt", mod.FileRestoredCalls[0].path); Assert.AreEqual(expectedTimestamp, mod.FileRestoredCalls[0].timestamp); } [Test] [Category("RestoreHandler")] public async Task InvokeFileRestoredIsolatedFromThrowingModulesAsync() { var throwing = new FakeRestoreCallbackModule { Key = "throw", OnFile = (_, _) => throw new InvalidOperationException("boom") }; var after = new FakeRestoreCallbackModule { Key = "after" }; await RestoreHandler.InvokeFileRestoredAsync(new IGenericModule[] { throwing, after }, 0, "/p", DateTime.MinValue, CancellationToken.None); Assert.AreEqual(1, after.FileRestoredCalls.Count); } [Test] [Category("RestoreHandler")] public async Task DispatchHelpersAcceptNullModulesAsync() { // Passing null must not throw. await RestoreHandler.InvokePreparePriorityFilesAsync(null, new List(), 0, DateTime.MinValue, CancellationToken.None); await RestoreHandler.InvokeBulkRestoreStartAsync(null, CancellationToken.None); await RestoreHandler.InvokeFileRestoredAsync(null, 0, "/p", DateTime.MinValue, CancellationToken.None); Assert.Pass(); } // ---------------------------------------------------------------------------------- // Integration tests: register a real module in the GenericLoader and run a restore. // ---------------------------------------------------------------------------------- /// /// A restore-callback module that records invocations into static state. Static /// state is required because creates a /// fresh instance per operation, so the instance that runs during the restore is not /// the same instance the test registered. /// public class RecordingRestoreCallbackModule : IRestoreCallbackModule { public const string KEY = "test-restore-callback"; public static readonly object RecordLock = new(); public static List<(string files, long version, DateTime timestamp)> PrepareCalls { get; } = new(); public static int BulkStartCalls; public static List<(long version, string path, DateTime timestamp)> FileRestoredCalls { get; } = new(); public static void Reset() { lock (RecordLock) { PrepareCalls.Clear(); BulkStartCalls = 0; FileRestoredCalls.Clear(); } } public string Key => KEY; public string DisplayName => "Test Restore Callback"; public string Description => "Records restore callback invocations for tests"; public bool LoadAsDefault => false; public IList SupportedCommands => new List(); public void Configure(IDictionary commandlineOptions) { } public Task OnPreparePriorityFilesAsync(IList priorityFiles, long version, DateTime backupTimestamp, CancellationToken cancellationToken) { lock (RecordLock) PrepareCalls.Add((string.Join("|", priorityFiles), version, backupTimestamp)); return Task.CompletedTask; } public Task OnBulkRestoreStartAsync(CancellationToken cancellationToken) { lock (RecordLock) BulkStartCalls++; return Task.CompletedTask; } public Task OnFileRestoredAsync(long version, string path, DateTime backupTimestamp, CancellationToken cancellationToken) { lock (RecordLock) FileRestoredCalls.Add((version, path, backupTimestamp)); return Task.CompletedTask; } } /// /// Registers into the static /// so it is discovered and activated during a restore /// operation (when enabled via --enable-module). Uses reflection because the /// loader's module table is not publicly writable. /// private static void RegisterRecordingModule() { var loaderType = typeof(GenericLoader); var loaderField = loaderType.GetField("_loader", BindingFlags.NonPublic | BindingFlags.Static) ?? throw new InvalidOperationException("Could not find GenericLoader._loader"); var lazy = loaderField.GetValue(null); var loaderSub = lazy!.GetType().GetProperty("Value")!.GetValue(lazy); var addModule = loaderSub!.GetType().GetMethod("AddModule", BindingFlags.Public | BindingFlags.Instance) ?? throw new InvalidOperationException("Could not find AddModule on loader"); addModule.Invoke(loaderSub, new object[] { new RecordingRestoreCallbackModule() }); } [OneTimeSetUp] public void OneTimeSetup() { RegisterRecordingModule(); } [Test] [Category("RestoreHandler")] public async Task RestoreCallbacksFireOnNewRestoreAsync() { RecordingRestoreCallbackModule.Reset(); // Write two files, one of which we mark as a priority file via the restore // destination provider (priority files are derived from the target folder). var file1 = Path.Combine(this.DATAFOLDER, "file1.txt"); var file2 = Path.Combine(this.DATAFOLDER, "file2.txt"); File.WriteAllText(file1, "content-1"); File.WriteAllText(file2, "content-2"); var backupOptions = new Dictionary(this.TestOptions); using (var c = new Controller("file://" + this.TARGETFOLDER, backupOptions, null)) TestUtils.AssertResults(await c.BackupAsync(new[] { this.DATAFOLDER })); var restoreOptions = new Dictionary(this.TestOptions) { ["restore-path"] = this.RESTOREFOLDER, ["restore-legacy"] = "false", ["enable-module"] = RecordingRestoreCallbackModule.KEY, }; var restoreStartedAt = DateTime.UtcNow; using (var c = new Controller("file://" + this.TARGETFOLDER, restoreOptions, null)) TestUtils.AssertResults(await c.RestoreAsync(new[] { "*" })); // OnPreparePriorityFilesAsync is always called (even with an empty list). Assert.That(RecordingRestoreCallbackModule.PrepareCalls.Count, Is.GreaterThanOrEqualTo(1), "OnPreparePriorityFilesAsync should be invoked"); // The reported version is 0 (newest backup) for the single restored version. Assert.AreEqual(0, RecordingRestoreCallbackModule.PrepareCalls[0].version, "Prepare should report version 0"); // The reported backup timestamp is the newest backup's timestamp, created just before this restore. Assert.That(RecordingRestoreCallbackModule.PrepareCalls[0].timestamp, Is.GreaterThan(restoreStartedAt.AddMinutes(-5)).And.LessThan(DateTime.UtcNow.AddMinutes(1)), "Prepare should report a recent backup timestamp"); // OnBulkRestoreStartAsync is called exactly once when the bulk restore starts. Assert.AreEqual(1, RecordingRestoreCallbackModule.BulkStartCalls, "OnBulkRestoreStartAsync should be invoked once"); // OnFileRestoredAsync is called for each restored file on the new engine. Assert.That(RecordingRestoreCallbackModule.FileRestoredCalls.Count, Is.GreaterThanOrEqualTo(2), "OnFileRestoredAsync should be invoked for each restored file"); // The reported version is 0 (newest backup) for the single restored version. Assert.IsTrue(RecordingRestoreCallbackModule.FileRestoredCalls.All(x => x.version == 0), "Restored files should report version 0"); // The reported backup timestamp is recent (the backup was created just before this restore). Assert.IsTrue(RecordingRestoreCallbackModule.FileRestoredCalls.All(x => x.timestamp > restoreStartedAt.AddMinutes(-5) && x.timestamp < DateTime.UtcNow.AddMinutes(1)), "Restored files should report a recent backup timestamp"); // The reported paths are within the restore folder. Assert.IsTrue(RecordingRestoreCallbackModule.FileRestoredCalls.All(x => x.path.StartsWith(this.RESTOREFOLDER, StringComparison.OrdinalIgnoreCase)), "Restored file paths should be under the restore folder"); } [Test] [Category("RestoreHandler")] public async Task OnFileRestoredNotCalledOnLegacyRestoreAsync() { RecordingRestoreCallbackModule.Reset(); var file1 = Path.Combine(this.DATAFOLDER, "legacy-file.txt"); File.WriteAllText(file1, "legacy-content"); var backupOptions = new Dictionary(this.TestOptions); using (var c = new Controller("file://" + this.TARGETFOLDER, backupOptions, null)) TestUtils.AssertResults(await c.BackupAsync(new[] { this.DATAFOLDER })); var restoreOptions = new Dictionary(this.TestOptions) { ["restore-path"] = this.RESTOREFOLDER, ["restore-legacy"] = "true", ["enable-module"] = RecordingRestoreCallbackModule.KEY, }; using (var c = new Controller("file://" + this.TARGETFOLDER, restoreOptions, null)) TestUtils.AssertResults(await c.RestoreAsync(new[] { "*" })); // OnPreparePriorityFilesAsync and OnBulkRestoreStartAsync are invoked on the legacy engine too. Assert.That(RecordingRestoreCallbackModule.PrepareCalls.Count, Is.GreaterThanOrEqualTo(1), "OnPreparePriorityFilesAsync should be invoked on legacy restore"); Assert.AreEqual(1, RecordingRestoreCallbackModule.BulkStartCalls, "OnBulkRestoreStartAsync should be invoked once on legacy restore"); // OnFileRestoredAsync must NOT be called when --restore-legacy is set. Assert.AreEqual(0, RecordingRestoreCallbackModule.FileRestoredCalls.Count, "OnFileRestoredAsync must not be called on legacy restore"); } [Test] [Category("RestoreHandler")] public async Task RestoreCallbackReceivesEmptyPriorityListWhenNoPriorityFilesAsync() { RecordingRestoreCallbackModule.Reset(); var file1 = Path.Combine(this.DATAFOLDER, "nopriority.txt"); File.WriteAllText(file1, "no-priority-content"); var backupOptions = new Dictionary(this.TestOptions); using (var c = new Controller("file://" + this.TARGETFOLDER, backupOptions, null)) TestUtils.AssertResults(await c.BackupAsync(new[] { this.DATAFOLDER })); var restoreOptions = new Dictionary(this.TestOptions) { ["restore-path"] = this.RESTOREFOLDER, ["restore-legacy"] = "false", ["enable-module"] = RecordingRestoreCallbackModule.KEY, }; using (var c = new Controller("file://" + this.TARGETFOLDER, restoreOptions, null)) TestUtils.AssertResults(await c.RestoreAsync(new[] { "*" })); // With no priority files, OnPreparePriorityFilesAsync is still called once with an empty list, // and OnBulkRestoreStartAsync is called exactly once (the bulk restore starts immediately). Assert.AreEqual(1, RecordingRestoreCallbackModule.PrepareCalls.Count, "OnPreparePriorityFilesAsync should be invoked exactly once"); Assert.AreEqual(string.Empty, RecordingRestoreCallbackModule.PrepareCalls[0].files, "Priority list should be empty"); Assert.AreEqual(1, RecordingRestoreCallbackModule.BulkStartCalls, "OnBulkRestoreStartAsync should be invoked once"); Assert.That(RecordingRestoreCallbackModule.FileRestoredCalls.Count, Is.GreaterThanOrEqualTo(1)); } } }