// 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; /// /// This class encapsulates a method for testing the correctness of the sync /// operation. It mirrors the approach taken by : /// 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 --sync-then-delete. 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. /// public class SyncCheckoutTest { /// /// The log tag /// private static readonly string LOGTAG = Library.Logging.Log.LogTagFromType(); /// /// A helper class to write debug messages to the log file /// 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); } } /// /// 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 --sync-then-delete. /// /// The folders to sync. Folder at index 0 is the base; all others are incrementals. /// The base options dictionary (the test will adjust sync-specific options). /// The target destination for the syncs. When null a local file:// target is used. public static async Task RunTestAsync(string[] folders, Dictionary 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(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); } /// /// Runs a single sync pass of the given source against the target. /// private static async Task RunSyncAsync(Library.Utility.TempFolder source, string target, Dictionary 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()); } /// /// 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. /// 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); } } /// /// 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). /// 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(); var mismatches = new List(); 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()); } } /// /// 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. /// 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 remote; using (var bk = Duplicati.Library.DynamicLoader.BackendLoader.GetBackend(target, new Dictionary())) { 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(); var sizeMismatch = new List(); 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()); } } /// /// Enumerates all files under returning their paths /// relative to using the OS directory separator. /// private static IEnumerable EnumerateRelativeFiles(string root) { root = Util.AppendDirSeparator(root); foreach (var f in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) yield return f.Substring(root.Length); } }