Files
duplicati/Duplicati/UnitTest/RestoreHandlerTests.cs
T
Kenneth Skovhede 84cc4005b1 Fix restore without local db
This fixes a bug where restoring with `--no-local-db` and `--version=0` would fail because the database consistency check sees that data is missing.

The missing data is intentional in this case, as we deliberately recreate a partial database.

The PR adds a test for this scenario and fixes the validation to allow for the partial database. Additionally, the partial database is marked as such, in case it is not correctly deleted after the restore.
2026-03-14 12:02:53 +01:00

353 lines
16 KiB
C#

// Copyright (C) 2025, 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.IO;
using Duplicati.Library.Interface;
using Duplicati.Library.Main;
using NUnit.Framework;
using Assert = NUnit.Framework.Legacy.ClassicAssert;
#nullable enable
namespace Duplicati.UnitTest
{
public class RestoreHandlerTests : BasicSetupHelper
{
[Test]
[Category("RestoreHandler")]
public void DisablePipedStreaming()
{
string filePath = Path.Combine(this.DATAFOLDER, "file");
File.WriteAllBytes(filePath, new byte[] { 0 });
Dictionary<string, string> options = new Dictionary<string, string>(this.TestOptions);
using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null))
{
c.Backup(new[] { this.DATAFOLDER });
}
Dictionary<string, string> restoreOptions = new Dictionary<string, string>(this.TestOptions) { ["restore-path"] = this.RESTOREFOLDER };
// This is now the default behavior, so we cannot explicitly disable it
//restoreOptions["disable-piped-streaming"] = "true";
using (Controller c = new Controller("file://" + this.TARGETFOLDER, restoreOptions, null))
{
IRestoreResults restoreResults = c.Restore(new[] { filePath });
Assert.AreEqual(0, restoreResults.Errors.Count());
Assert.AreEqual(0, restoreResults.Warnings.Count());
}
string restoredFilePath = Path.Combine(this.RESTOREFOLDER, "file");
Assert.IsTrue(File.Exists(restoredFilePath));
}
[Test]
[Category("RestoreHandler")]
public void RestoreEmptyFile()
{
string folderPath = Path.Combine(this.DATAFOLDER, "folder");
Directory.CreateDirectory(folderPath);
string filePath = Path.Combine(folderPath, "empty_file");
File.WriteAllBytes(filePath, new byte[] { });
Dictionary<string, string> options = new Dictionary<string, string>(this.TestOptions);
using (Controller c = new 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());
}
// Issue #4148 described a situation where the folders containing the empty file were not recreated properly.
Dictionary<string, string> restoreOptions = new Dictionary<string, string>(this.TestOptions)
{
["restore-path"] = this.RESTOREFOLDER,
["dont-compress-restore-paths"] = "true"
};
using (Controller c = new Controller("file://" + this.TARGETFOLDER, restoreOptions, null))
{
IRestoreResults restoreResults = c.Restore(new[] { filePath });
Assert.AreEqual(0, restoreResults.Errors.Count());
// TODO The expected warning is expected, as the 'dont-compress-restore-paths' option results in a warning about a folder not being created before restoring a file.
Assert.AreEqual(1, restoreResults.Warnings.Count());
}
// We need to strip the root part of the path. Otherwise, Path.Combine will simply return the second argument
// if it's determined to be an absolute path.
string rootString = SystemIO.IO_OS.GetPathRoot(filePath);
string newPathPart = filePath.Substring(rootString.Length);
if (OperatingSystem.IsWindows())
{
// On Windows, the drive letter is included in the path when the dont-compress-restore-paths option is used.
// The drive letter is assumed to be the first character of the path root (e.g., C:\).
newPathPart = Path.Combine(rootString.Substring(0, 1), filePath.Substring(rootString.Length));
}
string restoredFilePath = Path.Combine(restoreOptions["restore-path"], newPathPart);
Assert.IsTrue(File.Exists(restoredFilePath));
}
[Test]
[Category("RestoreHandler")]
public void RestoreInheritanceBreaks()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var folderPath = Path.Combine(this.DATAFOLDER, "folder");
Directory.CreateDirectory(folderPath);
var filePath = Path.Combine(folderPath, "file");
File.WriteAllBytes(filePath, new byte[] { 0 });
// Protect access rules on the file.
var fileSecurity = new FileInfo(filePath).GetAccessControl();
fileSecurity.SetAccessRuleProtection(true, true);
new FileInfo(filePath).SetAccessControl(fileSecurity);
var options = new Dictionary<string, string>(this.TestOptions);
using (var c = new Controller("file://" + this.TARGETFOLDER, options, null))
TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER }));
// First, restore without restoring permissions.
var restoreOptions = new Dictionary<string, string>(this.TestOptions) { ["restore-path"] = this.RESTOREFOLDER };
using (var c = new Controller("file://" + this.TARGETFOLDER, restoreOptions, null))
{
TestUtils.AssertResults(c.Restore(new[] { filePath }));
var restoredFilePath = Path.Combine(this.RESTOREFOLDER, "file");
Assert.IsTrue(File.Exists(restoredFilePath));
var restoredFileSecurity = new FileInfo(restoredFilePath).GetAccessControl();
Assert.IsFalse(restoredFileSecurity.AreAccessRulesProtected);
// Remove the restored file so that the later restore avoids the "Restore completed
// without errors but no files were restored" warning.
File.Delete(restoredFilePath);
}
// Restore with restoring permissions.
restoreOptions["overwrite"] = "true";
restoreOptions["restore-permissions"] = "true";
using (var c = new Controller("file://" + this.TARGETFOLDER, restoreOptions, null))
{
TestUtils.AssertResults(c.Restore(new[] { filePath }));
var restoredFilePath = Path.Combine(this.RESTOREFOLDER, "file");
Assert.IsTrue(File.Exists(restoredFilePath));
var restoredFileSecurity = new FileInfo(restoredFilePath).GetAccessControl();
Assert.IsTrue(restoredFileSecurity.AreAccessRulesProtected);
}
}
[Test]
[Category("RestoreHandler")]
public async System.Threading.Tasks.Task RestoreVolumeCache([Values("0b", "1mb", "5mb", "1gb")] string cache_size, [Values("0", "1", null)] string? channel_size)
{
var opts = TestOptions;
opts["dblock-size"] = "1mb";
opts["blocksize"] = "1kb";
opts["restore-volume-cache-hint"] = cache_size;
if (channel_size != null)
opts["restore-channel-buffer-size"] = channel_size;
opts["restore-legacy"] = "false";
opts["restore-file-processors"] = "4";
opts["restore-volume-decryptors"] = "4";
opts["restore-volume-decompressors"] = "4";
opts["restore-volume-downloaders"] = "4";
opts["restore-path"] = RESTOREFOLDER;
// Write a bunch of files
Random rng = new();
byte[] data = new byte[1024];
for (int i = 0; i < 1000; i++)
{
rng.NextBytes(data);
var filePath = Path.Combine(this.DATAFOLDER, $"file{i}");
File.WriteAllBytes(filePath, data);
}
using var c = new Controller("file://" + this.TARGETFOLDER, opts, null);
TestUtils.AssertResults(c.Backup([this.DATAFOLDER]));
// Start a 30 second timeout
var timeout_task = System.Threading.Tasks.Task.Delay(TimeSpan.FromSeconds(30));
var restore_task = System.Threading.Tasks.Task.Run(() =>
{
TestUtils.AssertResults(c.Restore(["*"]));
});
var t = await System.Threading.Tasks.Task.WhenAny(timeout_task, restore_task);
if (t == timeout_task)
{
c.Abort();
await restore_task; // Ensure we wait for the restore task to complete
Assert.Fail("Restore timed out");
}
else if (t == restore_task)
// Throw any exceptions it might have
t.GetAwaiter().GetResult();
TestUtils.AssertDirectoryTreesAreEquivalent(this.DATAFOLDER, this.RESTOREFOLDER, true, "Restoring with different volume cache sizes");
}
[Test]
[Category("RestoreHandler")]
public void RestoreWithoutLocalData([Values("true", "false")] string noLocalDb, [Values("true", "false")] string patchWithLocalBlocks)
{
var file1Path = Path.Combine(this.DATAFOLDER, "file1");
File.WriteAllBytes(file1Path, new byte[] { 1, 2, 3 });
var file2Path = Path.Combine(this.DATAFOLDER, "file2");
File.WriteAllBytes(file2Path, new byte[] { 3, 4, 6 });
var folderPath = Path.Combine(this.DATAFOLDER, "folder");
Directory.CreateDirectory(folderPath);
systemIO.FileCopy(file1Path, Path.Combine(folderPath, "file1 copy"), true);
using (Controller c = new Controller("file://" + this.TARGETFOLDER, new Dictionary<string, string>(this.TestOptions), null))
TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER }));
var restoreOptions = new Dictionary<string, string>(this.TestOptions)
{
["restore-path"] = this.RESTOREFOLDER,
["no-local-db"] = noLocalDb,
["restore-with-local-blocks"] = patchWithLocalBlocks
};
using (var c = new Controller("file://" + this.TARGETFOLDER, restoreOptions, null))
TestUtils.AssertResults(c.Restore(new[] { "*" }));
TestUtils.AssertDirectoryTreesAreEquivalent(this.DATAFOLDER, this.RESTOREFOLDER, true, "Restoring without local data");
}
[Test]
[Category("RestoreHandler")]
public void RestoreThreeVersionsWithoutLocalDb([Values(0, 1, 2)] int version)
{
// Create three versions with different file contents
var file1Path = Path.Combine(this.DATAFOLDER, "file1.txt");
var file2Path = Path.Combine(this.DATAFOLDER, "file2.txt");
// Version 0 (oldest): Create initial files
File.WriteAllText(file1Path, "version 0 content");
File.WriteAllText(file2Path, "version 0 file2");
using (Controller c = new Controller("file://" + this.TARGETFOLDER, new Dictionary<string, string>(this.TestOptions), null))
TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER }));
// Version 1: Modify file1, keep file2
File.WriteAllText(file1Path, "version 1 content");
using (Controller c = new Controller("file://" + this.TARGETFOLDER, new Dictionary<string, string>(this.TestOptions), null))
TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER }));
// Version 2 (newest): Modify both files
File.WriteAllText(file1Path, "version 2 content");
File.WriteAllText(file2Path, "version 2 file2");
using (Controller c = new Controller("file://" + this.TARGETFOLDER, new Dictionary<string, string>(this.TestOptions), null))
TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER }));
// Prepare expected content based on version being restored
// Note: In Duplicati, version 0 is the MOST RECENT backup
string expectedFile1Content;
string expectedFile2Content;
switch (version)
{
case 0: // Most recent (version 2 in our creation order)
expectedFile1Content = "version 2 content";
expectedFile2Content = "version 2 file2";
break;
case 1: // Second most recent (version 1 in our creation order)
expectedFile1Content = "version 1 content";
expectedFile2Content = "version 0 file2";
break;
case 2: // Oldest (version 0 in our creation order)
expectedFile1Content = "version 0 content";
expectedFile2Content = "version 0 file2";
break;
default:
throw new ArgumentException("Invalid version");
}
// Restore with --version and --no-local-db
var restoreOptions = new Dictionary<string, string>(this.TestOptions)
{
["restore-path"] = this.RESTOREFOLDER,
["version"] = version.ToString(),
["no-local-db"] = "true"
};
using (var c = new Controller("file://" + this.TARGETFOLDER, restoreOptions, null))
TestUtils.AssertResults(c.Restore(new[] { "*" }));
// Verify restored files match the expected version
var restoredFile1Path = Path.Combine(this.RESTOREFOLDER, "file1.txt");
var restoredFile2Path = Path.Combine(this.RESTOREFOLDER, "file2.txt");
Assert.IsTrue(File.Exists(restoredFile1Path), "Restored file1 should exist");
Assert.IsTrue(File.Exists(restoredFile2Path), "Restored file2 should exist");
Assert.AreEqual(expectedFile1Content, File.ReadAllText(restoredFile1Path), $"File1 content mismatch for version {version}");
Assert.AreEqual(expectedFile2Content, File.ReadAllText(restoredFile2Path), $"File2 content mismatch for version {version}");
}
[Test]
[Category("RestoreHandler")]
public void RestoreOtherProcessIsUsingFile()
{
var file1Path = Path.Combine(this.DATAFOLDER, "file1");
byte[] original_contents = [1, 2, 3];
File.WriteAllBytes(file1Path, original_contents);
var opts = new Dictionary<string, string>(this.TestOptions);
opts["overwrite"] = "true";
using var c = new Controller("file://" + this.TARGETFOLDER, opts, null);
var res_backup = c.Backup([this.DATAFOLDER]);
TestUtils.AssertResults(res_backup);
File.WriteAllBytes(file1Path, [4, 5, 6]);
using (var fs = new FileStream(file1Path, FileMode.Open, FileAccess.Read, FileShare.None))
{
var res_failing = c.Restore(["*"]);
Assert.AreEqual(4, res_failing.Errors.Count());
var first_error = res_failing.Errors.First();
Assert.IsTrue(
first_error.Contains("IOException: The process cannot access the file")
&&
first_error.EndsWith(" because it is being used by another process.")
);
}
var res_restore = c.Restore(["*"]);
TestUtils.AssertResults(res_restore);
Assert.AreEqual(original_contents, File.ReadAllBytes(file1Path));
}
}
}