Files
duplicati/Duplicati/UnitTest/SyncCheckoutTest.cs
T

358 lines
17 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 System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Duplicati.Library.Logging;
using Duplicati.Library.Common.IO;
using System.Threading.Tasks;
namespace Duplicati.UnitTest;
/// <summary>
/// This class encapsulates a method for testing the correctness of the sync
/// operation. It mirrors the approach taken by <see cref="SVNCheckoutTest"/>:
/// it iterates over a series of "versions" (folders), updating the source tree
/// to match each version, running a sync, and verifying that the remote
/// destination is in a consistent state after every version. "Consistent" here
/// means every local file is present on the remote with identical content, and
/// files removed from a folder that still exists locally are deleted from the
/// remote via <c>--sync-then-delete</c>. Sync does not prune whole subfolders
/// that no longer exist locally, so the verification is a presence+content
/// check rather than a full tree equivalence.
/// </summary>
public class SyncCheckoutTest
{
/// <summary>
/// The log tag
/// </summary>
private static readonly string LOGTAG = Library.Logging.Log.LogTagFromType<SyncCheckoutTest>();
/// <summary>
/// A helper class to write debug messages to the log file
/// </summary>
private class LogHelper : StreamLogDestination
{
public static long WarningCount = 0;
public static long ErrorCount = 0;
public LogHelper(string file)
: base(file)
{ }
public override void WriteMessage(LogEntry entry)
{
if (entry.Level == LogMessageType.Error)
System.Threading.Interlocked.Increment(ref ErrorCount);
else if (entry.Level == LogMessageType.Warning)
System.Threading.Interlocked.Increment(ref WarningCount);
base.WriteMessage(entry);
}
}
/// <summary>
/// Running the test confirms the correctness of the sync operation across a
/// series of source versions. For each version the source folder is updated
/// to match that version's contents (files added, modified, and removed),
/// a sync is run, and the remote destination is verified to consistently
/// mirror the source: every local file is present on the remote with
/// identical content, and removed files in folders that still exist locally
/// are deleted via <c>--sync-then-delete</c>.
/// </summary>
/// <param name="folders">The folders to sync. Folder at index 0 is the base; all others are incrementals.</param>
/// <param name="options">The base options dictionary (the test will adjust sync-specific options).</param>
/// <param name="target">The target destination for the syncs. When null a local file:// target is used.</param>
public static async Task RunTestAsync(string[] folders, Dictionary<string, string> options, string target)
{
string tempdir = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location), "tempdir_sync");
string logfilename = Path.Combine(tempdir, string.Format("unittest-sync-{0}.log", Library.Utility.Utility.SerializeDateTime(DateTime.Now)));
try
{
if (Directory.Exists(tempdir))
Directory.Delete(tempdir, true);
Directory.CreateDirectory(tempdir);
}
catch (Exception ex)
{
Console.WriteLine("Failed to clean tempdir: {0}", ex);
}
using (var log = new LogHelper(logfilename))
using (Log.StartScope(log, LogMessageType.Profiling))
{
// Filter empty entries, commonly occurring with copy/paste and newlines
folders = (from x in folders
where !string.IsNullOrWhiteSpace(x)
select Environment.ExpandEnvironmentVariables(x)).ToArray();
foreach (var f in folders)
foreach (var n in f.Split(new char[] { Path.PathSeparator }, StringSplitOptions.RemoveEmptyEntries))
if (!Directory.Exists(n))
throw new Exception(string.Format("Missing source folder: {0}", n));
Duplicati.Library.Utility.TempFolder.SystemTempPath = tempdir;
// Sync mirrors files unencrypted, so a passphrase is meaningless and
// must not be set. Remove any inherited passphrase and disable encryption.
options.Remove("passphrase");
options["no-encryption"] = "true";
if (!options.ContainsKey("prefix"))
options["prefix"] = "duplicati_unittest";
// We want all messages in the log
options["log-file-log-level"] = LogMessageType.Profiling.ToString();
// We use precise times so unchanged files are reliably detected.
options["disable-time-tolerance"] = "true";
// Sync deletes files removed locally only when --sync-then-delete is on.
// The whole point of iterating versions is to exercise updates AND deletes,
// so we always enable it here.
options["sync-then-delete"] = "true";
// The SVN dataset contains many files that keep the same size across versions
// but differ in content (e.g. Eclipse .cdtbuild / .settings prefs). Sync by
// default decides uploads on size+mtime alone, which would miss same-size
// changes; --sync-verify-hash makes sync re-check the content hash when size
// and mtime are unchanged, so updates are reliably detected across versions.
options["sync-verify-hash"] = "true";
using (new Timer(LOGTAG, "SyncUnitTest", "Total sync unittest"))
using (var sourceWork = new Library.Utility.TempFolder())
using (var defaultTarget = string.IsNullOrEmpty(target) ? new Library.Utility.TempFolder() : null)
{
options["dbpath"] = Path.Combine(tempdir, "unittest_sync.sqlite");
if (File.Exists(options["dbpath"]))
File.Delete(options["dbpath"]);
// When no target was supplied, use a distinct local file:// target so the
// destination tree is separate from the working source folder.
if (string.IsNullOrEmpty(target))
target = "file://" + defaultTarget;
// A dedicated target directory so the destination tree is distinct from
// the working source folder. Each version's verification reads from here.
string targetDir;
var isFileTarget = target.StartsWith("file://", StringComparison.Ordinal);
if (isFileTarget)
{
targetDir = target.Substring("file://".Length);
if (Directory.Exists(targetDir))
Directory.Delete(targetDir, true);
Directory.CreateDirectory(targetDir);
}
else
{
targetDir = null;
// For non-file backends, clean up any existing files on the remote first.
BasicSetupHelper.ProgressWriteLine("Removing old sync target contents");
var tmp = new Dictionary<string, string>(options) { ["force"] = "" };
try
{
using (var bk = Duplicati.Library.DynamicLoader.BackendLoader.GetBackend(target, tmp))
foreach (var f in bk.ListAsync(System.Threading.CancellationToken.None).ToBlockingEnumerable())
if (!f.IsFolder)
await bk.DeleteAsync(f.Name, System.Threading.CancellationToken.None);
}
catch (Duplicati.Library.Interface.FolderMissingException)
{
}
}
// Seed the working source folder with the first version's contents.
TestUtils.CopyDirectoryRecursive(folders[0], sourceWork);
await RunSyncAsync(sourceWork, target, options, folders[0]);
VerifyTargetMirrorsSource(folders[0], (string)sourceWork, target, targetDir);
for (int i = 1; i < folders.Length; i++)
{
// If the syncs are too close, mtime resolution can hide updates.
System.Threading.Thread.Sleep(1000 * 2);
// Replace the working source with the next version: this exercises
// adds, updates, and deletes (the latter via --sync-then-delete).
Directory.Delete(sourceWork, true);
TestUtils.CopyDirectoryRecursive(folders[i], sourceWork);
await RunSyncAsync(sourceWork, target, options, folders[i]);
VerifyTargetMirrorsSource(folders[i], (string)sourceWork, target, targetDir);
}
}
}
if (LogHelper.ErrorCount > 0)
BasicSetupHelper.ProgressWriteLine("Sync unittest completed, but with {0} errors, see logfile for details", LogHelper.ErrorCount);
else if (LogHelper.WarningCount > 0)
BasicSetupHelper.ProgressWriteLine("Sync unittest completed, but with {0} warnings, see logfile for details", LogHelper.WarningCount);
else
BasicSetupHelper.ProgressWriteLine("Sync unittest completed successfully - Have some cake!");
System.Diagnostics.Debug.Assert(LogHelper.ErrorCount == 0);
}
/// <summary>
/// Runs a single sync pass of the given source against the target.
/// </summary>
private static async Task RunSyncAsync(Library.Utility.TempFolder source, string target, Dictionary<string, string> options, string sourcename)
{
BasicSetupHelper.ProgressWriteLine("Syncing the copy: " + sourcename);
using (new Timer(LOGTAG, "SyncRun", "Sync of " + sourcename))
using (var console = new CommandLine.ConsoleOutput(Console.Out, options))
using (var i = new Duplicati.Library.Main.Controller(target, options, console))
Log.WriteInformationMessage(LOGTAG, "SyncOutput", (await i.SyncAsync(new[] { (string)source }, null)).ToString());
}
/// <summary>
/// Verifies the remote target exactly mirrors the source after a sync. For a
/// local file:// target the destination tree on disk is walked directly; for a
/// non-file backend the destination is listed. The sync contract verified here is:
/// (1) every local file is present on the remote with identical content, and
/// (2) within folders that still exist locally, files removed since the previous
/// version are deleted from the remote (--sync-then-delete). Sync does not prune
/// whole subfolders that no longer exist locally (folders are implicit, derived
/// from file presence, and deletes are scoped to folders the run still visits),
/// so the assertion is a presence+content check, not a full-tree equivalence.
/// </summary>
private static void VerifyTargetMirrorsSource(string sourceVersionName, string source, string target, string targetDir)
{
using (new Timer(LOGTAG, "SyncVerify", "Verification of sync of " + sourceVersionName))
{
if (targetDir != null)
VerifyLocalFilesPresent(source, targetDir, sourceVersionName);
else
VerifyRemoteFilesPresent(source, target, sourceVersionName);
}
}
/// <summary>
/// Verifies that every file in the source tree exists on the local file:// target
/// with identical size and content. This asserts the sync upload/update contract
/// (every local file is mirrored) without asserting the absence of stale files in
/// subfolders the sync no longer visits (see the contract note above).
/// </summary>
private static void VerifyLocalFilesPresent(string source, string targetDir, string sourceVersionName)
{
if (!Directory.Exists(targetDir))
{
var msg = $"Sync verification failed for {sourceVersionName}: target directory does not exist: {targetDir}";
Log.WriteErrorMessage(LOGTAG, "SyncVerifyTargetMissing", null, "{0}", msg);
throw new Exception(msg);
}
var missing = new List<string>();
var mismatches = new List<string>();
foreach (var rel in EnumerateRelativeFiles(source))
{
var remote = Path.Combine(targetDir, rel);
if (!File.Exists(remote))
{
missing.Add(rel);
continue;
}
var local = Path.Combine(source, rel);
try
{
TestUtils.AssertFilesAreEqual(local, remote, false, $"SyncVerify({sourceVersionName})");
}
catch (Exception)
{
mismatches.Add(rel);
}
}
if (missing.Count > 0 || mismatches.Count > 0)
{
var sb = new StringBuilder();
sb.AppendLine($"Sync verification failed for {sourceVersionName}:");
if (missing.Count > 0)
sb.AppendLine(" Missing on remote: " + string.Join(", ", missing.OrderBy(x => x)));
if (mismatches.Count > 0)
sb.AppendLine(" Content mismatch: " + string.Join(", ", mismatches.OrderBy(x => x)));
Log.WriteErrorMessage(LOGTAG, "SyncVerifyMismatch", null, "{0}", sb.ToString());
throw new Exception(sb.ToString());
}
}
/// <summary>
/// For a non-file backend, verifies that every local file is present on the remote
/// (by name and size). A full content download is not feasible across an arbitrary
/// backend, so this asserts presence and size only. As with the local path, extra
/// remote files in folders the sync no longer visits are not flagged.
/// </summary>
private static void VerifyRemoteFilesPresent(string source, string target, string sourceVersionName)
{
var expected = EnumerateRelativeFiles(source)
.Select(p => p.Replace('\\', '/'))
.OrderBy(x => x)
.ToDictionary(p => p, p => new FileInfo(Path.Combine(source, p.Replace('/', Path.DirectorySeparatorChar))).Length);
Dictionary<string, long> remote;
using (var bk = Duplicati.Library.DynamicLoader.BackendLoader.GetBackend(target, new Dictionary<string, string>()))
{
remote = bk.ListAsync(System.Threading.CancellationToken.None)
.ToBlockingEnumerable()
.Where(fe => !fe.IsFolder)
.ToDictionary(fe => fe.Name.Replace('\\', '/'), fe => Math.Max(fe.Size, 0L));
}
var missing = new List<string>();
var sizeMismatch = new List<string>();
foreach (var kv in expected)
{
if (!remote.TryGetValue(kv.Key, out var remoteSize))
missing.Add(kv.Key);
else if (remoteSize != kv.Value)
sizeMismatch.Add($"{kv.Key} (local {kv.Value}, remote {remoteSize})");
}
if (missing.Count > 0 || sizeMismatch.Count > 0)
{
var sb = new StringBuilder();
sb.AppendLine($"Sync verification failed for {sourceVersionName}:");
if (missing.Count > 0)
sb.AppendLine(" Missing on remote: " + string.Join(", ", missing.OrderBy(x => x)));
if (sizeMismatch.Count > 0)
sb.AppendLine(" Size mismatch: " + string.Join(", ", sizeMismatch.OrderBy(x => x)));
Log.WriteErrorMessage(LOGTAG, "SyncVerifyRemoteMissing", null, "{0}", sb.ToString());
throw new Exception(sb.ToString());
}
}
/// <summary>
/// Enumerates all files under <paramref name="root"/> returning their paths
/// relative to <paramref name="root"/> using the OS directory separator.
/// </summary>
private static IEnumerable<string> EnumerateRelativeFiles(string root)
{
root = Util.AppendDirSeparator(root);
foreach (var f in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
yield return f.Substring(root.Length);
}
}