This PR has a large blast radius because it takes the final step and bumps up the Controller to be fully async. We have historically done a piece-by-piece update, so all operations were already async but the controller interface was kept synchronous. With this update, the controller is now fully async and all tests are updated. Most places where the new C# compiler warns about function names not ending in `Async` were also adressed, giving a massive refactor change. Functionally, no changes are done.
938 lines
46 KiB
C#
938 lines
46 KiB
C#
// 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 Duplicati.Library.DynamicLoader;
|
|
using Duplicati.Library.Interface;
|
|
using Duplicati.Library.Main;
|
|
using Microsoft.Data.Sqlite;
|
|
using NUnit.Framework;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Runtime.Versioning;
|
|
using System.Security.AccessControl;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Principal;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
|
|
|
namespace Duplicati.UnitTest
|
|
{
|
|
public class IssueTests : BasicSetupHelper
|
|
{
|
|
|
|
[Test]
|
|
[Category("Targeted")]
|
|
public async Task Issue5023ReferencedFileMissingAsync([Values] bool compactBeforeRecreate)
|
|
{
|
|
// Reproduction for part of issue #5023
|
|
// Error during repair: "Remote file referenced as x by y, but not found in list, registering a missing remote file"
|
|
// Can be caused by interrupted index upload followed by compact before the repair
|
|
|
|
var testopts = new Dictionary<string, string>(TestOptions)
|
|
{
|
|
["number-of-retries"] = "0",
|
|
["dblock-size"] = "20KB",
|
|
["threshold"] = "1",
|
|
["keep-versions"] = "1",
|
|
["no-auto-compact"] = "true",
|
|
["no-encryption"] = "true"
|
|
};
|
|
|
|
const long filesize = 1024;
|
|
|
|
// First backup OK to create valid database
|
|
string target = "file://" + TARGETFOLDER;
|
|
string file1 = Path.Combine(DATAFOLDER, "f1");
|
|
TestUtils.WriteTestFile(file1, filesize);
|
|
using (var c = new Library.Main.Controller(target, testopts, null))
|
|
TestUtils.AssertResults(await c.BackupAsync(new[] { DATAFOLDER }));
|
|
|
|
// Sleep to ensure timestamps are different
|
|
Thread.Sleep(1000);
|
|
// Fail after index file put
|
|
target = new DeterministicErrorBackend().ProtocolKey + "://" + TARGETFOLDER;
|
|
string interruptedName = "";
|
|
DeterministicErrorBackend.ErrorGenerator = (DeterministicErrorBackend.BackendAction action, string remotename) =>
|
|
{
|
|
// Fail dindex upload
|
|
if (action == DeterministicErrorBackend.BackendAction.PutAfter && remotename.Contains("dindex"))
|
|
{
|
|
interruptedName = remotename;
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
BackendLoader.AddBackend(new DeterministicErrorBackend());
|
|
string file2 = Path.Combine(DATAFOLDER, "f2");
|
|
TestUtils.WriteTestFile(file2, filesize);
|
|
var uploadEx = Assert.CatchAsync(async () =>
|
|
{
|
|
using (var c = new Library.Main.Controller(target, testopts, null))
|
|
await c.BackupAsync(new[] { DATAFOLDER });
|
|
});
|
|
|
|
while (uploadEx is AggregateException && uploadEx.InnerException is not null)
|
|
uploadEx = uploadEx.InnerException;
|
|
|
|
Assert.That(uploadEx, Is.TypeOf<DeterministicErrorBackend.DeterministicErrorBackendException>());
|
|
|
|
Console.WriteLine("Interrupted after upload of {0}", interruptedName);
|
|
// Sleep to ensure timestamps are different
|
|
Thread.Sleep(1000);
|
|
// Complete upload
|
|
target = "file://" + TARGETFOLDER;
|
|
using (var c = new Library.Main.Controller(target, testopts, null))
|
|
TestUtils.AssertResults(await c.BackupAsync(new[] { DATAFOLDER }));
|
|
|
|
// At this point there are two index files for the last dblock
|
|
Console.WriteLine("Target folder contents (expect extra index file):");
|
|
Console.WriteLine(string.Join("\n", from v in Directory.EnumerateFiles(TARGETFOLDER) select Path.GetFileName(v)));
|
|
|
|
// Note: If there is a recreate here (between the above extra file creation and the following compact),
|
|
// the bug does not occur as the extra index file will be recorded in the database and properly deleted by compact
|
|
|
|
if (compactBeforeRecreate)
|
|
{
|
|
// Make room for a backup
|
|
Thread.Sleep(2000);
|
|
|
|
// Delete f2 and compact
|
|
File.Delete(file2);
|
|
using (var c = new Controller(target, testopts, null))
|
|
{
|
|
var backupResults = await c.BackupAsync(new[] { DATAFOLDER });
|
|
TestUtils.AssertResults(backupResults);
|
|
ICompactResults compactResults = await c.CompactAsync();
|
|
TestUtils.AssertResults(compactResults);
|
|
Assert.Greater(compactResults.DeletedFileCount, 0);
|
|
}
|
|
// At this point there are two index files for the last dblock
|
|
Console.WriteLine("Target folder contents (expect extra index file):");
|
|
Console.WriteLine(string.Join("\n", from v in Directory.EnumerateFiles(TARGETFOLDER) select Path.GetFileName(v)));
|
|
}
|
|
|
|
// Database recreate should work (fails after compact)
|
|
File.Delete(DBFILE);
|
|
using (var c = new Library.Main.Controller(target, testopts, null))
|
|
TestUtils.AssertResults(await c.RepairAsync());
|
|
}
|
|
|
|
|
|
[Test]
|
|
[Category("Restore"), Category("Bug")]
|
|
public async Task Issue5825RestoreNoOverwriteAsync([Values] bool legacy, [Values] bool local_blocks)
|
|
{
|
|
// Reproduction of Issue #5825
|
|
// The logic in the previous version was to create a timestamped version of the file being restored to, if it already exists.
|
|
// It appears the new restore flow is not correctly doing the same.
|
|
// See forum thread: https://forum.duplicati.com/t/save-different-versions-with-timestamp-in-file-name-broken/19805
|
|
|
|
var testopts = new Dictionary<string, string>(TestOptions)
|
|
{
|
|
["restore-legacy"] = legacy.ToString().ToLower(),
|
|
["restore-with-local-blocks"] = local_blocks.ToString().ToLower()
|
|
};
|
|
int test_filesize = 1024;
|
|
|
|
// TODO check if the original files are untocuched
|
|
|
|
var original_dir = Path.Combine(DATAFOLDER, "some_original_dir");
|
|
Directory.CreateDirectory(original_dir);
|
|
string f0 = Path.Combine(original_dir, "some_file");
|
|
TestUtils.WriteTestFile(f0, test_filesize);
|
|
|
|
// Backup the files
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
TestUtils.AssertResults(await c.BackupAsync([DATAFOLDER]));
|
|
|
|
// Attempt to restore the file
|
|
testopts["restore-path"] = RESTOREFOLDER;
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
{
|
|
var restoreResults = await c.RestoreAsync([f0]);
|
|
Assert.That(restoreResults.RestoredFiles, Is.EqualTo(1), "File should have been restored");
|
|
}
|
|
|
|
// Verify that the files are equal
|
|
string f1 = Path.Combine(RESTOREFOLDER, "some_file");
|
|
Assert.That(File.ReadAllBytes(f0), Is.EqualTo(File.ReadAllBytes(f1)), "Restored file should be equal to original file");
|
|
|
|
// Modify the restored file
|
|
TestUtils.WriteTestFile(f1, test_filesize);
|
|
|
|
// Restore the file again, with overwrite.
|
|
testopts["overwrite"] = "true";
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
{
|
|
var restoreResults = await c.RestoreAsync([f0]);
|
|
Assert.That(restoreResults.RestoredFiles, Is.EqualTo(1), "File should have been restored");
|
|
}
|
|
|
|
// Verify that the files are equal
|
|
Assert.That(File.ReadAllBytes(f0), Is.EqualTo(File.ReadAllBytes(f1)), "Restored file should be equal to original file");
|
|
|
|
// Save the timestamp of the file
|
|
var timestamp = File.GetLastWriteTime(f1);
|
|
|
|
// Touch the file
|
|
File.SetLastWriteTime(f1, DateTime.Now);
|
|
|
|
// Check that the timestamp has changed, but the file is still equal
|
|
Assert.That(File.GetLastWriteTime(f1), Is.Not.EqualTo(timestamp), "Timestamp should have changed");
|
|
Assert.That(File.ReadAllBytes(f0), Is.EqualTo(File.ReadAllBytes(f1)), "Restored file should be equal to original file");
|
|
|
|
// Restore the file again, with overwrite, should restore the timestamp of the file
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
{
|
|
var restoreResults = await c.RestoreAsync([f0]);
|
|
Assert.That(restoreResults.RestoredFiles, Is.EqualTo(0), "File should not have been restored, only the metadata.");
|
|
}
|
|
|
|
// Verify that the files are equal and the timestamp is restored
|
|
Assert.That(File.ReadAllBytes(f0), Is.EqualTo(File.ReadAllBytes(f1)), "Restored file should be equal to original file");
|
|
Assert.That(File.GetLastWriteTime(f1), Is.EqualTo(timestamp), "Timestamp should be restored");
|
|
|
|
// Modify the restored file
|
|
TestUtils.WriteTestFile(f1, test_filesize);
|
|
var f1_original = File.ReadAllBytes(f1);
|
|
|
|
// Restore the file again, without overwrite.
|
|
testopts["overwrite"] = "false";
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
{
|
|
var restoreResults = await c.RestoreAsync([f0]);
|
|
Assert.That(restoreResults.RestoredFiles, Is.EqualTo(1), "File should have been restored");
|
|
}
|
|
|
|
// Verify that f1 is still the same
|
|
Assert.That(File.ReadAllBytes(f1), Is.EqualTo(f1_original), "The first restored file should remain untouched");
|
|
|
|
// Verify that there exists a new file with a timestamp
|
|
var files = Directory.GetFiles(RESTOREFOLDER, "*", SearchOption.TopDirectoryOnly);
|
|
Assert.That(files.Length, Is.EqualTo(2), "There should be two files in the folder");
|
|
var f2 = files.FirstOrDefault(v => v != f1);
|
|
|
|
// Modify the new restored file as well
|
|
TestUtils.WriteTestFile(f2, test_filesize);
|
|
var f2_original = File.ReadAllBytes(f2);
|
|
|
|
// Restore the file again, without overwrite.
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
{
|
|
var restoreResults = await c.RestoreAsync([f0]);
|
|
Assert.That(restoreResults.RestoredFiles, Is.EqualTo(1), "File should have been restored");
|
|
}
|
|
|
|
// Verify that f1 and f2 are still the same
|
|
Assert.That(File.ReadAllBytes(f1), Is.EqualTo(f1_original), "The first restored file should remain untouched");
|
|
Assert.That(File.ReadAllBytes(f2), Is.EqualTo(f2_original), "The second restored file should remain untouched");
|
|
|
|
// Verify that there exists a new file with a timestamp
|
|
files = Directory.GetFiles(RESTOREFOLDER, "*", SearchOption.TopDirectoryOnly);
|
|
Assert.That(files.Length, Is.EqualTo(3), "There should be three files in the folder");
|
|
|
|
// Verify that the files are equal
|
|
var f3 = files.FirstOrDefault(v => v != f1 && v != f2);
|
|
Assert.That(File.ReadAllBytes(f0), Is.EqualTo(File.ReadAllBytes(f3)), "Restored file should be equal to original file");
|
|
|
|
// Modify the second file to match the original file - should not restore any files
|
|
File.WriteAllBytes(f2, File.ReadAllBytes(f0));
|
|
|
|
// Restore the file again, without overwrite.
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
{
|
|
var restoreResults = await c.RestoreAsync([f0]);
|
|
Assert.That(restoreResults.RestoredFiles, Is.EqualTo(0), "File should not have been restored");
|
|
}
|
|
|
|
// Verify that f1 is still untouched
|
|
Assert.That(File.ReadAllBytes(f1), Is.EqualTo(f1_original), "The first restored file should remain untouched");
|
|
}
|
|
|
|
|
|
[Test]
|
|
[Category("Restore"), Category("Bug")]
|
|
public async Task Issue5826RestoreMissingFolderAsync([Values] bool compressRestorePaths, [Values] bool restoreLegacy)
|
|
{
|
|
// Reproduction of Issue #5826
|
|
// With the new restore engine, it looks like a file will not be restored if the folder is not selected for restore.
|
|
// See forum issue for details: https://forum.duplicati.com/t/could-not-find-a-part-of-the-path/19775
|
|
|
|
var testopts = new Dictionary<string, string>(TestOptions)
|
|
{
|
|
["dont-compress-restore-paths"] = (!compressRestorePaths).ToString().ToLower(),
|
|
["restore-legacy"] = restoreLegacy.ToString().ToLower()
|
|
};
|
|
|
|
var original_dir = Path.Combine(DATAFOLDER, "some_original_dir");
|
|
Directory.CreateDirectory(original_dir);
|
|
string f = Path.Combine(original_dir, "some_file");
|
|
TestUtils.WriteTestFile(f, 1024 * 20);
|
|
|
|
// Backup the files
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
TestUtils.AssertResults(await c.BackupAsync([DATAFOLDER]));
|
|
|
|
// Attempt to restore a file without selecting its parent folder
|
|
testopts["restore-path"] = RESTOREFOLDER;
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
{
|
|
var restoreResults = await c.RestoreAsync([f]);
|
|
Assert.That(restoreResults.RestoredFiles, Is.EqualTo(1), "File should have been restored.");
|
|
Assert.That(restoreResults.Warnings.Count(), Is.EqualTo(compressRestorePaths ? 0 : 1), "Warning should be generated for missing folder");
|
|
}
|
|
}
|
|
|
|
|
|
[Test]
|
|
[Category("Restore"), Category("Bug")]
|
|
public async Task Issue5886RestoreModifiedMiddleBlockAsync()
|
|
{
|
|
var blocksize = 1024;
|
|
var n_blocks = 5;
|
|
var testopts = new Dictionary<string, string>(TestOptions)
|
|
{
|
|
["blocksize"] = $"{blocksize}b",
|
|
["overwrite"] = "true"
|
|
};
|
|
|
|
var original_dir = Path.Combine(DATAFOLDER, "some_original_dir");
|
|
Directory.CreateDirectory(original_dir);
|
|
string f = Path.Combine(original_dir, "some_file");
|
|
TestUtils.WriteTestFile(f, n_blocks * blocksize);
|
|
|
|
// Backup the files
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
TestUtils.AssertResults(await c.BackupAsync([DATAFOLDER]));
|
|
|
|
var original_contents = File.ReadAllBytes(f);
|
|
var new_block = new byte[blocksize];
|
|
new Random(5888).NextBytes(new_block);
|
|
var new_contents = new byte[n_blocks * blocksize];
|
|
|
|
for (int i = 0; i < n_blocks; i++)
|
|
{
|
|
for (int j = i; j < n_blocks; j++)
|
|
{
|
|
// Modify the blocks
|
|
for (int k = 0; k < n_blocks; k++)
|
|
{
|
|
if (k == i || k == j)
|
|
{
|
|
Array.Copy(new_block, 0, new_contents, k * blocksize, blocksize);
|
|
}
|
|
else
|
|
{
|
|
Array.Copy(original_contents, k * blocksize, new_contents, k * blocksize, blocksize);
|
|
}
|
|
}
|
|
|
|
// Write the modified file
|
|
File.WriteAllBytes(f, new_contents);
|
|
var restored_contents = File.ReadAllBytes(f);
|
|
Assert.That(restored_contents, Is.Not.EqualTo(original_contents), "Restored file should not be equal to original file");
|
|
|
|
// Attempt to restore the file
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
{
|
|
var restoreResults = await c.RestoreAsync([f]);
|
|
Assert.That(restoreResults.RestoredFiles, Is.EqualTo(1), "File should have been restored.");
|
|
}
|
|
|
|
// Verify that the files are equal
|
|
restored_contents = File.ReadAllBytes(f);
|
|
Assert.That(restored_contents, Is.EqualTo(original_contents), "Restored file should be equal to original file");
|
|
}
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
[Category("Restore"), Category("Bug")]
|
|
public async Task Issue5957RestoreModifiedBlockUseLocalBlocksAsync([Values] bool use_local, [Values] bool overwrite)
|
|
{
|
|
var blocksize = 1024;
|
|
var testopts = new Dictionary<string, string>(TestOptions)
|
|
{
|
|
["blocksize"] = $"{blocksize}b",
|
|
["restore-with-local-blocks"] = use_local.ToString().ToLower(),
|
|
["overwrite"] = overwrite.ToString().ToLower()
|
|
};
|
|
|
|
var original_dir = Path.Combine(DATAFOLDER, "some_original_dir");
|
|
Directory.CreateDirectory(original_dir);
|
|
string f = Path.Combine(original_dir, "some_file");
|
|
TestUtils.WriteTestFile(f, 3 * blocksize);
|
|
|
|
// Backup the files
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
TestUtils.AssertResults(await c.BackupAsync([DATAFOLDER]));
|
|
|
|
var original_contents = File.ReadAllBytes(f);
|
|
|
|
using (var stream = File.Open(f, FileMode.Open, FileAccess.ReadWrite))
|
|
{
|
|
stream.Seek(0, SeekOrigin.Begin);
|
|
var buffer = new byte[1];
|
|
if (await stream.ReadAsync(buffer, 0, 1) < 1)
|
|
throw new InvalidOperationException("Failed to read from test file");
|
|
buffer[0] = (byte)~buffer[0];
|
|
stream.Seek(0, SeekOrigin.Begin);
|
|
await stream.WriteAsync(buffer, 0, 1);
|
|
}
|
|
|
|
var restored_contents = File.ReadAllBytes(f);
|
|
Assert.That(restored_contents, Is.Not.EqualTo(original_contents), "Restored file should not be equal to original file");
|
|
|
|
// Attempt to restore the file
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
{
|
|
var restoreResults = await c.RestoreAsync([f]);
|
|
Assert.That(restoreResults.RestoredFiles, Is.EqualTo(1), "File should have been restored.");
|
|
}
|
|
|
|
// Verify that the files are equal
|
|
// List files in folder
|
|
if (overwrite)
|
|
{
|
|
restored_contents = File.ReadAllBytes(f);
|
|
}
|
|
else
|
|
{
|
|
var files = Directory.GetFiles(original_dir, "*", SearchOption.TopDirectoryOnly);
|
|
Assert.That(files.Length, Is.EqualTo(2), "There should be two files in the folder");
|
|
var f2 = files.FirstOrDefault(v => v != f);
|
|
restored_contents = File.ReadAllBytes(f2);
|
|
}
|
|
Assert.That(restored_contents, Is.EqualTo(original_contents), "Restored file should be equal to original file");
|
|
}
|
|
|
|
|
|
[Test]
|
|
[Category("Restore"), Category("Bug")]
|
|
public async Task Issue6068FolderMetadataAsync([Values] bool restoreLegacy)
|
|
{
|
|
// Reproduction of Issue #6068
|
|
// The folder metadata is not restored
|
|
|
|
var testopts = new Dictionary<string, string>(TestOptions);
|
|
|
|
var original_dir_name = "some_original_dir";
|
|
var original_dir = Path.Combine(DATAFOLDER, original_dir_name);
|
|
var restored_dir = Path.Combine(RESTOREFOLDER, original_dir_name);
|
|
Directory.CreateDirectory(original_dir);
|
|
|
|
// Backup the files
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
TestUtils.AssertResults(await c.BackupAsync([DATAFOLDER]));
|
|
|
|
// Sleep to ensure timestamps are different
|
|
await Task.Delay(1000);
|
|
|
|
// Attempt to restore to another folder
|
|
testopts["restore-path"] = RESTOREFOLDER;
|
|
testopts["restore-legacy"] = restoreLegacy.ToString().ToLower();
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
{
|
|
var restoreResults = await c.RestoreAsync([]);
|
|
Assert.That(restoreResults.RestoredFiles, Is.EqualTo(0), "No files should have been restored.");
|
|
}
|
|
|
|
// Assert that the original folders date modified and date created are the same as the restored folder
|
|
var original_dir_info = new DirectoryInfo(original_dir);
|
|
var restored_dir_info = new DirectoryInfo(restored_dir);
|
|
Assert.That(original_dir_info.CreationTime, Is.EqualTo(restored_dir_info.CreationTime), "Creation time should be equal");
|
|
Assert.That(original_dir_info.LastWriteTime, Is.EqualTo(restored_dir_info.LastWriteTime), "Last write time should be equal");
|
|
}
|
|
|
|
|
|
[Test]
|
|
[Category("Test"), Category("Bug")]
|
|
public async Task Issue6459HashNullInTestAsync()
|
|
{
|
|
// Reproduction of the issue outlined in https://forum.duplicati.com/t/value-cannot-be-null-parameter-hash-cannot-be-null/20958
|
|
// This test is to ensure that a null hash in the database does not cause issues during backup and repair operations.
|
|
var testopts = new Dictionary<string, string>(TestOptions)
|
|
{
|
|
["full-remote-verification"] = "true"
|
|
};
|
|
testopts.Remove("backup-test-samples");
|
|
|
|
// Create some test files
|
|
Directory.CreateDirectory(DATAFOLDER);
|
|
string f = Path.Combine(DATAFOLDER, "some_file");
|
|
TestUtils.WriteTestFile($"{f}_0.txt", 1024);
|
|
|
|
// Backup the files
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
TestUtils.AssertResults(await c.BackupAsync([DATAFOLDER]));
|
|
|
|
// Set one of the hashes in the database to null
|
|
{
|
|
using var con = new SqliteConnection($"Data source={DBFILE};Pooling=false");
|
|
await con.OpenAsync();
|
|
using var cmd = con.CreateCommand();
|
|
using var transaction = con.BeginTransaction();
|
|
cmd.Transaction = transaction;
|
|
cmd.CommandText = @"UPDATE Remotevolume SET Hash = NULL WHERE Name LIKE '%.dblock.%'";
|
|
await cmd.ExecuteNonQueryAsync();
|
|
await transaction.CommitAsync();
|
|
}
|
|
|
|
// Add another file and perform the backup again.
|
|
// This would fail prior to the fix.
|
|
TestUtils.WriteTestFile($"{f}_1.txt", 1024);
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
TestUtils.AssertResults(await c.BackupAsync([DATAFOLDER]));
|
|
|
|
// Delete and recreate the database to ensure the null hash is not present anymore
|
|
File.Delete(DBFILE);
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
TestUtils.AssertResults(await c.RepairAsync());
|
|
|
|
// Double check that the null hash is not present anymore
|
|
{
|
|
using var con = new SqliteConnection($"Data source={DBFILE};Pooling=false");
|
|
await con.OpenAsync();
|
|
using var cmd = con.CreateCommand();
|
|
cmd.CommandText = @"SELECT COUNT(*) FROM Remotevolume WHERE Hash IS NULL";
|
|
var count = (long)await cmd.ExecuteScalarAsync();
|
|
Assert.That(count, Is.EqualTo(0), "There should be no null hashes in the database.");
|
|
}
|
|
|
|
// Perform a final test backup to ensure everything is working correctly
|
|
TestUtils.WriteTestFile($"{f}_2.txt", 1024);
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
{
|
|
IBackupResults backupResults = await c.BackupAsync([DATAFOLDER]);
|
|
TestUtils.AssertResults(backupResults);
|
|
}
|
|
}
|
|
|
|
|
|
[Test]
|
|
[Category("Restore"), Category("Bug")]
|
|
public async Task Issue6068FileAndFolderAttributesAndPermissionsAsync([Values] bool restorePermissions, [Values] bool restoreLegacy, [Values] bool skip_metadata)
|
|
{
|
|
// This test is to verify that permissions are restored correctly
|
|
// if the restore-permissions option is set, and that the
|
|
// attributes are restored regardless. Some discussion is in the
|
|
// related PR #6079
|
|
|
|
// Set the options according to the test parameters
|
|
var testopts = new Dictionary<string, string>(TestOptions)
|
|
{
|
|
["restore-permissions"] = restorePermissions.ToString().ToLower(),
|
|
["restore-legacy"] = restoreLegacy.ToString().ToLower(),
|
|
["skip-metadata"] = skip_metadata.ToString().ToLower()
|
|
};
|
|
|
|
// The attributes to test
|
|
var attrs = new FileAttributes[] {
|
|
FileAttributes.ReadOnly,
|
|
FileAttributes.Hidden,
|
|
FileAttributes.System,
|
|
// TODO The Archive attribute has been disabled for now, as the CI doesn't behave as expected, but it works locally.
|
|
//FileAttributes.Archive
|
|
};
|
|
|
|
// Create the generated directories and files
|
|
var original_dir = Path.Combine(DATAFOLDER, "some_original_dir");
|
|
|
|
var dirs = attrs
|
|
.Select(x => Path.Combine(original_dir, $"dir_{x}"))
|
|
.Prepend(original_dir)
|
|
.ToArray();
|
|
|
|
foreach (var dir in dirs)
|
|
Directory.CreateDirectory(dir);
|
|
|
|
foreach (var attr in attrs)
|
|
foreach (var dir in dirs)
|
|
{
|
|
var file = Path.Combine(dir, $"file_{attr}");
|
|
TestUtils.WriteTestFile(file, 1024);
|
|
File.SetAttributes(file, attr);
|
|
}
|
|
|
|
foreach (var (attr, dir) in attrs.Zip(dirs.Skip(1)))
|
|
_ = new DirectoryInfo(dir)
|
|
{
|
|
Attributes = attr
|
|
};
|
|
|
|
// Create OS specific files and directories
|
|
var os_special_dir = Path.Combine(original_dir, "os_special_dir");
|
|
Directory.CreateDirectory(os_special_dir);
|
|
var default_dir_attrs = new DirectoryInfo(os_special_dir).Attributes;
|
|
var os_special_file = Path.Combine(os_special_dir, "os_special_file");
|
|
TestUtils.WriteTestFile(os_special_file, 1024);
|
|
var default_file_attrs = File.GetAttributes(os_special_file);
|
|
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
// Set only the current user to have access to both the file and the directory
|
|
var dir_info = new DirectoryInfo(os_special_dir);
|
|
var rules_dir = dir_info.GetAccessControl();
|
|
rules_dir.GetAccessRules(true, true, typeof(System.Security.Principal.NTAccount));
|
|
rules_dir.AddAccessRule(new FileSystemAccessRule(Environment.UserName, FileSystemRights.FullControl, AccessControlType.Allow));
|
|
rules_dir.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(WellKnownSidType.WorldSid, null), FileSystemRights.ReadAndExecute, AccessControlType.Allow));
|
|
dir_info.SetAccessControl(rules_dir);
|
|
|
|
var file_info = new FileInfo(os_special_file);
|
|
var rules_file = file_info.GetAccessControl();
|
|
rules_file.AddAccessRule(new FileSystemAccessRule(Environment.UserName, FileSystemRights.FullControl, AccessControlType.Allow));
|
|
rules_file.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(WellKnownSidType.WorldSid, null), FileSystemRights.ReadAndExecute, AccessControlType.Allow));
|
|
file_info.SetAccessControl(rules_file);
|
|
}
|
|
else // Mac and Linux
|
|
{
|
|
// Set the dir to 700 and the file to 600
|
|
_ = new DirectoryInfo(os_special_dir)
|
|
{
|
|
UnixFileMode = UnixFileMode.UserExecute | UnixFileMode.UserRead | UnixFileMode.UserWrite
|
|
};
|
|
_ = new FileInfo(os_special_file)
|
|
{
|
|
UnixFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite
|
|
};
|
|
}
|
|
|
|
try
|
|
{
|
|
// Backup the files
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
TestUtils.AssertResults(await c.BackupAsync([DATAFOLDER]));
|
|
|
|
// Restore the files
|
|
testopts["restore-path"] = RESTOREFOLDER;
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
|
await c.RestoreAsync([]);
|
|
|
|
// Check the folder attributes
|
|
foreach (var dir in dirs.Skip(1))
|
|
{
|
|
var restored_dir = dir.Replace(DATAFOLDER, RESTOREFOLDER);
|
|
var original_attrs = new DirectoryInfo(dir).Attributes;
|
|
var restored_attrs = new DirectoryInfo(restored_dir).Attributes;
|
|
if (skip_metadata && original_attrs != default_dir_attrs)
|
|
Assert.That(original_attrs, Is.Not.EqualTo(restored_attrs), $"Directory attributes should not be equal for directory: {dir}");
|
|
else
|
|
Assert.That(original_attrs, Is.EqualTo(restored_attrs), $"Directory attributes should be equal for directory: {dir}");
|
|
}
|
|
|
|
// Check the file attributes
|
|
foreach (var attr in attrs)
|
|
foreach (var dir in dirs)
|
|
{
|
|
var filename = $"file_{attr}";
|
|
var original_file = Path.Combine(dir, filename);
|
|
var restored_file = original_file.Replace(DATAFOLDER, RESTOREFOLDER);
|
|
// TODO we ignore the Archive attribute, as the CI sets this sporadically
|
|
var original_attrs = File.GetAttributes(original_file);
|
|
var restored_attrs = File.GetAttributes(restored_file);
|
|
if (skip_metadata && original_attrs != default_file_attrs)
|
|
Assert.That(original_attrs & ~FileAttributes.Archive, Is.Not.EqualTo(restored_attrs & ~FileAttributes.Archive), $"File attributes should not be equal for original file: {original_file}");
|
|
else
|
|
Assert.That(original_attrs & ~FileAttributes.Archive, Is.EqualTo(restored_attrs & ~FileAttributes.Archive), $"File attributes should be equal for original file: {original_file}");
|
|
}
|
|
|
|
// Check the OS specific file and directory
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
var original_dir_info = new DirectoryInfo(os_special_dir);
|
|
var restored_dir_info = new DirectoryInfo(os_special_dir.Replace(DATAFOLDER, RESTOREFOLDER));
|
|
var original_dir_rules = original_dir_info.GetAccessControl().GetAccessRules(true, false, typeof(System.Security.Principal.NTAccount));
|
|
var restored_dir_rules = restored_dir_info.GetAccessControl().GetAccessRules(true, false, typeof(System.Security.Principal.NTAccount));
|
|
|
|
if (skip_metadata && original_dir_info.Attributes != default_dir_attrs)
|
|
Assert.That(original_dir_info.Attributes, Is.Not.EqualTo(restored_dir_info.Attributes), "Directory attributes should not be equal");
|
|
else
|
|
Assert.That(original_dir_info.Attributes, Is.EqualTo(restored_dir_info.Attributes), "Directory attributes should be equal");
|
|
|
|
var dir_permissions_equal = CompareCollection(original_dir_rules, restored_dir_rules);
|
|
if (restorePermissions && !skip_metadata)
|
|
Assert.That(dir_permissions_equal, Is.True, "Directory permissions should be equal");
|
|
else
|
|
Assert.That(dir_permissions_equal, Is.False, "Directory permissions should not be equal");
|
|
|
|
var original_file_info = new FileInfo(os_special_file);
|
|
var restored_file_info = new FileInfo(os_special_file.Replace(DATAFOLDER, RESTOREFOLDER));
|
|
var original_file_rules = original_file_info.GetAccessControl().GetAccessRules(true, false, typeof(System.Security.Principal.NTAccount));
|
|
var restored_file_rules = restored_file_info.GetAccessControl().GetAccessRules(true, false, typeof(System.Security.Principal.NTAccount));
|
|
|
|
if (skip_metadata && original_file_info.Attributes != default_file_attrs)
|
|
Assert.That(original_file_info.Attributes, Is.Not.EqualTo(restored_file_info.Attributes), "File attributes should not be equal");
|
|
else
|
|
Assert.That(original_file_info.Attributes, Is.EqualTo(restored_file_info.Attributes), "File attributes should be equal");
|
|
|
|
var file_permissions_equal = CompareCollection(original_file_rules, restored_file_rules);
|
|
if (restorePermissions && !skip_metadata)
|
|
Assert.That(file_permissions_equal, Is.True, "File permissions should be equal");
|
|
else
|
|
Assert.That(file_permissions_equal, Is.False, "File permissions should not be equal");
|
|
}
|
|
else
|
|
{
|
|
var original_dir_info = new DirectoryInfo(os_special_dir);
|
|
var restored_dir_info = new DirectoryInfo(os_special_dir.Replace(DATAFOLDER, RESTOREFOLDER));
|
|
|
|
if (skip_metadata && original_dir_info.Attributes != default_dir_attrs)
|
|
Assert.That(original_dir_info.Attributes, Is.Not.EqualTo(restored_dir_info.Attributes), "Directory attributes should not be equal");
|
|
else
|
|
Assert.That(original_dir_info.Attributes, Is.EqualTo(restored_dir_info.Attributes), "Directory attributes should be equal");
|
|
|
|
if (restorePermissions && !skip_metadata)
|
|
Assert.That(original_dir_info.UnixFileMode, Is.EqualTo(restored_dir_info.UnixFileMode), "Directory permissions should be equal");
|
|
else
|
|
Assert.That(original_dir_info.UnixFileMode, Is.Not.EqualTo(restored_dir_info.UnixFileMode), "Directory permissions should not be equal");
|
|
|
|
var original_file_info = new FileInfo(os_special_file);
|
|
var restored_file_info = new FileInfo(os_special_file.Replace(DATAFOLDER, RESTOREFOLDER));
|
|
|
|
if (skip_metadata && original_file_info.Attributes != default_file_attrs)
|
|
Assert.That(original_file_info.Attributes, Is.Not.EqualTo(restored_file_info.Attributes), "File attributes should not be equal");
|
|
else
|
|
Assert.That(original_file_info.Attributes, Is.EqualTo(restored_file_info.Attributes), "File attributes should be equal");
|
|
|
|
if (restorePermissions && !skip_metadata)
|
|
Assert.That(original_file_info.UnixFileMode, Is.EqualTo(restored_file_info.UnixFileMode), "File permissions should be equal");
|
|
else
|
|
Assert.That(original_file_info.UnixFileMode, Is.Not.EqualTo(restored_file_info.UnixFileMode), "File permissions should not be equal");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
// Revert the attributes so the cleanup can proceed
|
|
foreach (var dir in dirs)
|
|
{
|
|
_ = new DirectoryInfo(dir)
|
|
{
|
|
Attributes = FileAttributes.Normal | FileAttributes.Directory
|
|
};
|
|
var restored_dir = dir.Replace(DATAFOLDER, RESTOREFOLDER);
|
|
if (Directory.Exists(restored_dir))
|
|
_ = new DirectoryInfo(restored_dir)
|
|
{
|
|
Attributes = FileAttributes.Normal | FileAttributes.Directory
|
|
};
|
|
}
|
|
|
|
foreach (var attr in attrs)
|
|
foreach (var dir in dirs)
|
|
{
|
|
var file = Path.Combine(dir, $"file_{attr}");
|
|
File.SetAttributes(file, FileAttributes.Normal);
|
|
var restored_file = file.Replace(DATAFOLDER, RESTOREFOLDER);
|
|
if (File.Exists(restored_file))
|
|
File.SetAttributes(restored_file, FileAttributes.Normal);
|
|
}
|
|
|
|
// Special dirs can be ignored, as they should only block other
|
|
// users from accessing the files
|
|
}
|
|
}
|
|
|
|
[SupportedOSPlatform("windows")]
|
|
static bool CompareElement(FileSystemAccessRule a, FileSystemAccessRule b) =>
|
|
a.IdentityReference.Value == b.IdentityReference.Value &&
|
|
a.FileSystemRights == b.FileSystemRights &&
|
|
a.AccessControlType == b.AccessControlType;
|
|
|
|
// Use order-independent comparison since ACL rules may be returned in different orders
|
|
[SupportedOSPlatform("windows")]
|
|
static bool CompareCollection(AuthorizationRuleCollection a, AuthorizationRuleCollection b) =>
|
|
a.Count == b.Count &&
|
|
a.Cast<FileSystemAccessRule>().All(ruleA =>
|
|
b.Cast<FileSystemAccessRule>().Any(ruleB => CompareElement(ruleA, ruleB)));
|
|
|
|
|
|
|
|
[Test]
|
|
[Category("Disruption"), Category("Bug")]
|
|
public async Task TestSystematicErrors5023Async()
|
|
{
|
|
// Attempt to recreate other bugs from #5023, but not successful
|
|
var testopts = new Dictionary<string, string>(TestOptions)
|
|
{
|
|
["number-of-retries"] = "0",
|
|
["dblock-size"] = "20KB",
|
|
["threshold"] = "1",
|
|
["keep-versions"] = "5",
|
|
["no-encryption"] = "true",
|
|
["disable-synthetic-filelist"] = "true"
|
|
};
|
|
//testopts["rebuild-missing-dblock-files"] = "true";
|
|
string target = "file://" + TARGETFOLDER;
|
|
string targetError = new DeterministicErrorBackend().ProtocolKey + "://" + TARGETFOLDER;
|
|
int maxFiles = 10;
|
|
List<string> files = new List<string>();
|
|
bool failed = false;
|
|
long accessCounter = 0;
|
|
long errorIdx = 0;
|
|
DeterministicErrorBackend.ErrorGenerator = (DeterministicErrorBackend.BackendAction action, string remotename) =>
|
|
{
|
|
++accessCounter;
|
|
if (accessCounter >= errorIdx)
|
|
{
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
for (int i = 0; i < maxFiles; ++i)
|
|
{
|
|
string f = Path.Combine(DATAFOLDER, "f" + i);
|
|
TestUtils.WriteTestFile(f, 1024 * 20);
|
|
files.Add(f);
|
|
}
|
|
// Initial backup
|
|
using (var c = new Library.Main.Controller(target, testopts, null))
|
|
TestUtils.AssertResults(await c.BackupAsync(new[] { DATAFOLDER }));
|
|
|
|
while (errorIdx < (maxFiles + 2))
|
|
{
|
|
if (errorIdx % 10 == 0)
|
|
{
|
|
TestContext.WriteLine("Error index {0}", errorIdx);
|
|
}
|
|
accessCounter = 0;
|
|
try
|
|
{
|
|
using (var c = new Library.Main.Controller(targetError, testopts, null))
|
|
TestUtils.AssertResults(await c.BackupAsync(new[] { DATAFOLDER }));
|
|
}
|
|
catch (AssertionException) { throw; }
|
|
catch { }
|
|
Thread.Sleep(1000);
|
|
try
|
|
{
|
|
using (var c = new Library.Main.Controller(target, testopts, null))
|
|
TestUtils.AssertResults(await c.BackupAsync(new[] { DATAFOLDER }));
|
|
}
|
|
catch (UserInformationException e)
|
|
{
|
|
TestContext.WriteLine("Error at index {0}: HelpID={1}, Message={2}", errorIdx, e.HelpID, e.Message);
|
|
if (e.HelpID == "MissingRemoteFiles" || e.HelpID == "ExtraRemoteFiles" || e.HelpID == "DatabaseRepairInProgress")
|
|
{
|
|
// If a database repair failed, we need to delete the database file
|
|
if (e.HelpID == "DatabaseRepairInProgress")
|
|
File.Delete(DBFILE);
|
|
|
|
using (var c = new Library.Main.Controller(target, testopts, null))
|
|
TestUtils.AssertResults(await c.RepairAsync());
|
|
}
|
|
else if (e.HelpID == "DatabaseTimestampError")
|
|
{
|
|
// Clock skew detected, wait and retry - this can happen on CI due to timing issues
|
|
TestContext.WriteLine("Clock skew detected at index {0}, waiting 10 seconds before continuing", errorIdx);
|
|
Thread.Sleep(10000);
|
|
}
|
|
else
|
|
{
|
|
TestContext.WriteLine("Unexpected error at index {0}: HelpID={1}, Message={2}", errorIdx, e.HelpID, e.Message);
|
|
failed = true;
|
|
}
|
|
}
|
|
Thread.Sleep(1000);
|
|
foreach (string f in files)
|
|
{
|
|
TestUtils.WriteTestFile(f, 1024 * 20);
|
|
}
|
|
++errorIdx;
|
|
}
|
|
TestContext.WriteLine("Ran {0} iterations", errorIdx);
|
|
Assert.IsFalse(failed);
|
|
}
|
|
|
|
[Test, Sequential]
|
|
[Category("Targeted"), Category("Bug"), Category("Non-critical")]
|
|
[TestCase(false, true), TestCase(true, true), TestCase(true, false)]
|
|
public async Task Issue5038MissingListBlocklistAsync(bool sameVersion, bool blockFirst)
|
|
{
|
|
// Backup containing the blocklist of a file BEFORE the file causes a dindex with missing blocklist entry
|
|
// This is not critical, because it only requires extra block volume downloads
|
|
var testopts = new Dictionary<string, string>(TestOptions)
|
|
{
|
|
["no-encryption"] = "true"
|
|
};
|
|
|
|
string filename = Path.Combine(DATAFOLDER, "file");
|
|
// Start with z to process blockfile after file (at least on some systems)
|
|
string blockfile = Path.Combine(DATAFOLDER, blockFirst ? "block" : "zblock");
|
|
string target = "file://" + TARGETFOLDER;
|
|
|
|
byte[] block1 = new byte[10 * 1024];
|
|
block1.AsSpan().Fill(1);
|
|
byte[] block2 = new byte[10 * 1024];
|
|
block2.AsSpan().Fill(2);
|
|
|
|
HashAlgorithm blockhasher = Library.Utility.HashFactory.CreateHasher(new Options(testopts).BlockHashAlgorithm);
|
|
|
|
var hash1 = blockhasher.ComputeHash(block1, 0, block1.Length);
|
|
var hash2 = blockhasher.ComputeHash(block2, 0, block2.Length);
|
|
|
|
byte[] blockfileContent = hash1.Concat(hash2).ToArray();
|
|
TestUtils.WriteFile(blockfile, blockfileContent);
|
|
if (!sameVersion)
|
|
{
|
|
// Backup blockfile first
|
|
using (var c = new Library.Main.Controller(target, testopts, null))
|
|
TestUtils.AssertResults(await c.BackupAsync(new[] { DATAFOLDER }));
|
|
}
|
|
|
|
byte[] combined = block1.Concat(block2).ToArray();
|
|
TestUtils.WriteFile(filename, combined);
|
|
// Backup file that would produce blockfile
|
|
using (var c = new Library.Main.Controller(target, testopts, null))
|
|
TestUtils.AssertResults(await c.BackupAsync(new[] { DATAFOLDER }));
|
|
|
|
// Recreate database downloads block volume
|
|
File.Delete(DBFILE);
|
|
using (var c = new Library.Main.Controller(target, testopts, null))
|
|
{
|
|
var repairResults = await c.RepairAsync();
|
|
TestUtils.AssertResults(repairResults);
|
|
Assert.IsNull(repairResults.Messages.FirstOrDefault(v => v.Contains("ProcessingRequiredBlocklistVolumes")),
|
|
"Blocklist download pass was required");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task Issue6817DollarSignNumberInFilenameBreaksRegexAsync()
|
|
{
|
|
var filename = "~$1234567891011121314151617181920.txt";
|
|
var content = RandomNumberGenerator.GetBytes(1024);
|
|
TestUtils.WriteFile(Path.Combine(DATAFOLDER, filename), content);
|
|
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, TestOptions, null))
|
|
TestUtils.AssertResults(await c.BackupAsync([DATAFOLDER]));
|
|
|
|
// Delete the database
|
|
File.Delete(DBFILE);
|
|
|
|
// Recreate the database, which would fail if the filename is not handled correctly
|
|
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, TestOptions, null))
|
|
TestUtils.AssertResults(await c.RepairAsync());
|
|
}
|
|
}
|
|
}
|