// 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 Duplicati.Library.Backend;
using Duplicati.Library.Common.IO;
using Duplicati.Library.Interface;
using Duplicati.Library.Localization.Short;
using Duplicati.Library.Main.Backend;
using Duplicati.Library.Logging;
using Duplicati.Library.Utility;
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace RemoteSynchronization
{
///
/// Global configuration for the tool. Should be set after parsing the commandline arguments.
///
public sealed record Config
(
// Arguments
string Src,
string Dst,
// Options
bool AutoCreateFolders,
int BackendRetries,
int BackendRetryDelay,
bool BackendRetryWithExponentialBackoff,
bool Confirm,
bool DryRun,
List DstOptions,
bool Force,
List GlobalOptions,
string LogFile,
string LogLevel,
bool ParseArgumentsOnly,
bool Progress,
bool Retention,
int Retry,
List SrcOptions,
bool VerifyContents,
bool VerifyGetAfterPut
);
///
/// Remote synchronization tool.
///
public static class RemoteSynchronizationRunner
{
///
/// The log tag for this tool.
///
private static readonly string LOGTAG = Duplicati.Library.Logging.Log.LogTagFromType(typeof(RemoteSynchronizationRunner));
///
/// Entry point for running the tool logic with parsed arguments.
///
/// The commandline arguments
/// 0 on success, -1 on abort, and the number of errors encountered otherwise.
public static async Task RunAsync(string[] args)
{
var arg_src = new Argument(name: "backend_src", description: Strings.SourceBackendDescription);
var arg_dst = new Argument(name: "backend_dst", description: Strings.DestinationBackendDescription);
var root_cmd = new RootCommand(Strings.RootCommandDescription)
{
arg_src,
arg_dst,
new Option(aliases: ["--auto-create-folders"], description: Strings.AutoCreateFoldersDescription, getDefaultValue: () => true),
new Option(aliases: ["--backend-retries"], description: Strings.BackendRetriesDescription, getDefaultValue: () => 3) { Arity = ArgumentArity.ExactlyOne },
new Option(aliases: ["--backend-retry-delay"], description: Strings.BackendRetryDelayDescription, getDefaultValue: () => 1000) { Arity = ArgumentArity.ExactlyOne },
new Option(aliases: ["--backend-retry-with-exponential-backoff"], description: Strings.BackendRetryWithExponentialBackoffDescription, getDefaultValue: () => true),
new Option(aliases: ["--confirm", "--yes", "-y"], description: Strings.ConfirmDescription, getDefaultValue: () => false),
new Option(aliases: ["--dry-run", "-d"], description: Strings.DryRunDescription, getDefaultValue: () => false),
OptionWithMultipleTokens(aliases: ["--dst-options"], description: Strings.DstOptionsDescription, getDefaultValue: () => []),
new Option(aliases: ["--force", "-f"], description: Strings.ForceDescription, getDefaultValue: () => false),
OptionWithMultipleTokens(aliases: ["--global-options"], description: Strings.GlobalOptionsDescription, getDefaultValue: () => []),
new Option(aliases: ["--log-file"], description: Strings.LogFileDescription, getDefaultValue: () => "") { Arity = ArgumentArity.ExactlyOne },
new Option(aliases: ["--log-level"], description: Strings.LogLevelDescription, getDefaultValue: () => "Information") { Arity = ArgumentArity.ExactlyOne },
new Option(aliases: ["--parse-arguments-only"], description: Strings.ParseArgumentsOnlyDescription, getDefaultValue: () => false),
new Option(aliases: ["--progress"], description: Strings.ProgressDescription, getDefaultValue: () => false),
new Option(aliases: ["--retention"], description: Strings.RetentionDescription, getDefaultValue: () => false),
new Option(aliases: ["--retry"], description: Strings.RetryDescription, getDefaultValue: () => 3) { Arity = ArgumentArity.ExactlyOne },
OptionWithMultipleTokens(aliases: ["--src-options"], description: Strings.SrcOptionsDescription, getDefaultValue: () => []),
new Option(aliases: ["--verify-contents"], description: Strings.VerifyContentsDescription, getDefaultValue: () => false),
new Option(aliases: ["--verify-get-after-put"], description: Strings.VerifyGetAfterPutDescription, getDefaultValue: () => false),
};
root_cmd.Handler = CommandHandler.Create((string backend_src, string backend_dst, Config config, CancellationToken token) =>
{
var config_with_args = config with { Dst = backend_dst, Src = backend_src };
return Run(config_with_args, token);
});
return await root_cmd.InvokeAsync(args).ConfigureAwait(false);
}
///
/// The main logic of the tool.
///
/// The parsed configuration for the tool.
/// The cancellation token to use for the asynchronous operations.
/// The return code for the main entry; 0 on success.
public static async Task Run(Config config, CancellationToken token)
{
// Parse the log level
var log_level_parsed = Enum.TryParse(config.LogLevel, true, out var log_level_enum);
log_level_enum = log_level_parsed ? log_level_enum : Duplicati.Library.Logging.LogMessageType.Information;
if (!string.IsNullOrEmpty(config.LogFile))
{
using var console_sink = new StreamLogDestination(Console.OpenStandardOutput());
// Parse the log file
StreamLogDestination? log_file_sink = null;
if (!string.IsNullOrEmpty(config.LogFile))
{
string log_file_dir = SystemIO.IO_OS.PathGetDirectoryName(config.LogFile);
if (!string.IsNullOrEmpty(log_file_dir) && !SystemIO.IO_OS.DirectoryExists(log_file_dir))
SystemIO.IO_OS.DirectoryCreate(log_file_dir);
log_file_sink = new StreamLogDestination(config.LogFile);
}
using var multi_sink = new MultiLogDestination([console_sink, log_file_sink]);
// Start the logging scope
using var _ = Duplicati.Library.Logging.Log.StartScope(multi_sink, log_level_enum);
return await RunCore(config, token).ConfigureAwait(false);
}
else
{
return await RunCore(config, token).ConfigureAwait(false);
}
}
///
/// The core logic of the tool, without logging setup.
///
/// The parsed configuration for the tool.
/// The cancellation token to use for the asynchronous operations.
/// The return code for the main entry; 0 on success.
private static async Task RunCore(Config config, CancellationToken token)
{
// Unpack and parse the multi token options
var global_options = ParseOptions(config.GlobalOptions);
var src_opts = ParseOptions(config.SrcOptions);
var dst_opts = ParseOptions(config.DstOptions);
// Merge the global options into the source and destination options. The global options will be overridden by the source and destination options.
foreach (var x in global_options)
{
if (!src_opts.ContainsKey(x.Key))
src_opts[x.Key] = x.Value;
if (!dst_opts.ContainsKey(x.Key))
dst_opts[x.Key] = x.Value;
}
// Check if we only had to parse the arguments
if (config.ParseArgumentsOnly)
{
Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync", "Arguments parsed successfully; {0}; exiting.", config);
return 0;
}
using var b1m = new LightWeightBackendManager(config.Src, src_opts, config.BackendRetries, config.BackendRetryDelay, config.BackendRetryWithExponentialBackoff);
using var b2m = new LightWeightBackendManager(config.Dst, dst_opts, config.BackendRetries, config.BackendRetryDelay, config.BackendRetryWithExponentialBackoff);
// Prepare the operations
var (to_copy, to_delete, to_verify) = await PrepareFileLists(b1m, b2m, config, token).ConfigureAwait(false);
// Verify the files if requested. If the files are not verified, they will be deleted and copied again.
long verified = 0, failed_verify = 0;
if (config.VerifyContents)
{
// As this is a potentially slow operation, ask for confirmation of the verification)
if (!config.Confirm)
{
Console.WriteLine($"This will verify {to_verify.Count()} files before copying them. Do you want to continue? [y/N]");
var response = Console.ReadLine();
if (!response?.Equals("y", StringComparison.CurrentCultureIgnoreCase) ?? true)
{
Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync", "Aborted");
return -1;
}
}
var not_verified = await VerifyAsync(b1m, b2m, to_verify, config, token).ConfigureAwait(false);
failed_verify = not_verified.Count();
verified = to_verify.Count() - failed_verify;
if (not_verified.Any())
{
Duplicati.Library.Logging.Log.WriteWarningMessage(LOGTAG, "rsync", null,
"{0} files failed verification. They will be deleted and copied again.",
not_verified.Count());
to_delete = to_delete.Concat(not_verified);
to_copy = to_copy.Concat(not_verified);
}
}
Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync",
"The remote synchronization plan is to {0} {1} files from {2}, then copy {3} files from {4} to {2}.",
config.Retention ? "rename" : "delete",
to_delete.Count(), b2m.DisplayName, to_copy.Count(), b1m.DisplayName);
// As this is a potentially destructive operation, ask for confirmation
if (!config.Confirm)
{
var delete_rename = config.Retention ? "Rename" : "Delete";
Console.WriteLine($"This will perform the following actions (in order):");
Console.WriteLine($" - {delete_rename} {to_delete.Count()} files from {config.Dst}");
Console.WriteLine($" - Copy {to_copy.Count()} files from {config.Src} to {config.Dst}");
Console.WriteLine();
Console.WriteLine("Do you want to continue? [y/N]");
var response = Console.ReadLine();
if (!response?.Equals("y", StringComparison.CurrentCultureIgnoreCase) ?? true)
{
Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync", "Aborted");
return -1;
}
}
// Delete or rename the files that are not needed
long renamed = 0, deleted = 0;
if (config.Retention)
{
renamed = await RenameAsync(b2m, to_delete, config, token).ConfigureAwait(false);
Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync",
"Renamed {0} files in {1}", renamed, b2m.DisplayName);
}
else
{
deleted = await DeleteAsync(b2m, to_delete, config, token).ConfigureAwait(false);
Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync",
"Deleted {0} files from {1}", deleted, b2m.DisplayName);
}
// Copy the files
var (copied, copy_errors) = await CopyAsync(b1m, b2m, to_copy, config, token).ConfigureAwait(false);
Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync",
"Copied {0} files from {1} to {2}", copied, b1m.DisplayName, b2m.DisplayName);
// If there are still errors, retry a few times
if (copy_errors.Any())
{
Duplicati.Library.Logging.Log.WriteWarningMessage(LOGTAG, "rsync", null,
"Could not copy {0} files.", copy_errors.Count());
if (config.Retry > 0)
{
Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync",
"Retrying {0} more times to copy the {1} files that failed",
config.Retry, copy_errors.Count());
for (int i = 0; i < config.Retry; i++)
{
await Task.Delay(5000).ConfigureAwait(false); // Wait 5 seconds before retrying
(copied, copy_errors) = await CopyAsync(b1m, b2m, copy_errors, config, token).ConfigureAwait(false);
Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync",
"Copied {0} files from {1} to {2}", copied, b1m.DisplayName, b2m.DisplayName);
if (!copy_errors.Any())
break;
}
}
if (copy_errors.Any())
{
Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "rsync", null,
"Could not copy {0} files. Not retrying any more.", copy_errors.Count());
return copy_errors.Count();
}
}
// Results reporting
if (verified > 0)
Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync",
"Verified {0} files in {1} that didn't need to be copied",
verified, b2m.DisplayName);
if (failed_verify > 0)
Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync",
"Failed to verify {0} files in {1}, which were then attempted to be copied",
failed_verify, b2m.DisplayName);
if (copied > 0)
Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync",
"Copied {0} files from {1} to {2}", copied, b1m.DisplayName, b2m.DisplayName);
if (deleted > 0)
Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync",
"Deleted {0} files from {1}", deleted, b2m.DisplayName);
if (renamed > 0)
Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync",
"Renamed {0} files in {1}", renamed, b2m.DisplayName);
Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync",
"Remote synchronization completed successfully");
return 0;
}
// TODO have concurrency parameters: uploaders, downloaders
// TODO low memory mode, where things aren't kept in memory. Maybe utilize SQLite?
// TODO For convenience, have the option to launch a "duplicati test" on the destination backend after the synchronization
// TODO Save hash to minimize redownload
// TODO Duplicati Results
///
/// Copies the files from one backend to another.
/// The files are copied one by one, and each file is verified after uploading if the verify flag is set.
///
/// The source backend.
/// The destination backend.
/// The files that will be copied.
/// The parsed configuration for the tool.
/// The cancellation token to use for the asynchronous operations.
/// A tuple holding the number of succesful copies and a List of the files that failed.
private static async Task<(long, IEnumerable)> CopyAsync(LightWeightBackendManager b_src, LightWeightBackendManager b_dst, IEnumerable files, Config config, CancellationToken token)
{
long successful_copies = 0;
List errors = [];
long i = 0, n = files.Count();
var sw_get_src = new System.Diagnostics.Stopwatch();
var sw_put_dst = new System.Diagnostics.Stopwatch();
var sw_get_dst = new System.Diagnostics.Stopwatch();
var sw_get_cmp = new System.Diagnostics.Stopwatch();
foreach (var f in files)
{
if (config.Progress)
Console.Write($"\rCopying: {i}/{n}");
Duplicati.Library.Logging.Log.WriteVerboseMessage(LOGTAG, "rsync",
"Copying {0} from {1} to {2}", f.Name, b_src.DisplayName, b_dst.DisplayName);
using var s_src = Duplicati.Library.Utility.TempFileStream.Create();
try
{
sw_get_src.Start();
await b_src.GetAsync(f.Name, s_src, token).ConfigureAwait(false);
s_src.Position = 0;
sw_get_src.Stop();
if (config.DryRun)
{
Duplicati.Library.Logging.Log.WriteDryrunMessage(LOGTAG, "rsync",
"Would write {0} bytes of {1} to {2}",
Duplicati.Library.Utility.Utility.FormatSizeString(s_src.Length),
f.Name, b_dst.DisplayName);
}
else
{
sw_put_dst.Start();
await b_dst.PutAsync(f.Name, s_src, token).ConfigureAwait(false);
s_src.Position = 0;
sw_put_dst.Stop();
if (config.VerifyGetAfterPut)
{
// Start calculating the hash of the source file while we are downloading
var srchashtask = Task.Run(() =>
{
using var hasher = HashFactory.CreateHasher("SHA256");
return Convert.ToBase64String(hasher.ComputeHash(s_src));
});
using var s_dst = Duplicati.Library.Utility.TempFileStream.Create();
sw_get_dst.Start();
await b_dst.GetAsync(f.Name, s_dst, token).ConfigureAwait(false);
s_dst.Position = 0;
sw_get_dst.Stop();
sw_get_cmp.Start();
string? err_string = null;
if (s_src.Length != s_dst.Length)
{
err_string = $"The sizes of the files do not match: {s_src.Length} != {s_dst.Length}.";
}
using var hasher = HashFactory.CreateHasher("SHA256");
var dsthash = Convert.ToBase64String(hasher.ComputeHash(s_dst));
if (await srchashtask.ConfigureAwait(false) != dsthash)
{
err_string = (err_string is null ? "" : err_string + " ") + "The contents of the files do not match.";
}
sw_get_cmp.Stop();
if (err_string is not null)
{
throw new Exception(err_string);
}
}
}
successful_copies++;
}
catch (Exception e)
{
Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "rsync", e,
"Error copying {0}: {1}", f.Name, e.Message);
errors.Add(f);
}
finally
{
i++;
// Stop any running timers
sw_get_src.Stop();
sw_put_dst.Stop();
sw_get_dst.Stop();
sw_get_cmp.Stop();
}
}
if (config.Progress)
Console.WriteLine($"\rCopying: {n}/{n}");
Duplicati.Library.Logging.Log.WriteProfilingMessage(LOGTAG, "rsync",
"Copy | Get source: {0} ms, Put destination: {1} ms, Get destination: {2} ms, Get compare: {3} ms",
TimeSpan.FromMilliseconds(sw_get_src.ElapsedMilliseconds),
TimeSpan.FromMilliseconds(sw_put_dst.ElapsedMilliseconds),
TimeSpan.FromMilliseconds(sw_get_dst.ElapsedMilliseconds),
TimeSpan.FromMilliseconds(sw_get_cmp.ElapsedMilliseconds));
return (successful_copies, errors);
}
///
/// Deletes the files from a backend.
///
/// The backend to delete the files from.
/// The files to delete.
/// The parsed configuration for the tool.
/// The cancellation token to use for the asynchronous operations.
/// The number of successful deletions.
private static async Task DeleteAsync(LightWeightBackendManager b, IEnumerable files, Config config, CancellationToken token)
{
long successful_deletes = 0;
long i = 0, n = files.Count();
using var timer = new Duplicati.Library.Logging.Timer(LOGTAG, "rsync", "Delete operation");
foreach (var f in files)
{
if (n > 1 && config.Progress)
{
Console.Write($"\rDeleting: {i}/{n}");
}
Duplicati.Library.Logging.Log.WriteVerboseMessage(LOGTAG, "rsync",
"Deleting {0} from {1}", f.Name, b.DisplayName);
try
{
if (config.DryRun)
{
Duplicati.Library.Logging.Log.WriteDryrunMessage(LOGTAG, "rsync",
"Would delete {0} from {1}", f.Name, b.DisplayName);
}
else
{
await b.DeleteAsync(f.Name, token).ConfigureAwait(false);
}
successful_deletes++;
}
catch (Exception e)
{
Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "rsync", e,
"Error deleting {0}: {1}", f.Name, e.Message);
}
i++;
}
if (config.Progress)
Console.WriteLine($"\rDeleting: {n}/{n}");
return successful_deletes;
}
///
/// Creates an option that allows multiple tokens and multiple arguments per token.
///
/// The aliases for the option.
/// The description for the option.
/// The created option.
private static Option> OptionWithMultipleTokens(string[] aliases, string description, Func> getDefaultValue)
{
return new Option>(aliases: aliases, description: description, getDefaultValue: getDefaultValue)
{
Arity = ArgumentArity.OneOrMore,
AllowMultipleArgumentsPerToken = true
};
}
///
/// A log destination that writes to multiple destinations.
///
private sealed class MultiLogDestination : ILogDestination, IDisposable
{
private readonly List m_destinations;
///
/// Initializes a new instance of the MultiLogDestination class.
///
/// The log destinations to write to.
public MultiLogDestination(IEnumerable destinations)
{
m_destinations = destinations.Where(x => x != null).Cast().ToList();
}
///
/// Writes a log entry to all destinations.
///
/// The log entry to write.
public void WriteMessage(LogEntry entry)
{
foreach (var destination in m_destinations)
destination.WriteMessage(entry);
}
///
/// Disposes of the resources used by this instance.
///
public void Dispose()
{
foreach (var destination in m_destinations)
if (destination is IDisposable disposable)
disposable.Dispose();
}
}
///
/// Parses the options from a list of strings.
/// Each option should be in the format "key=value". If the value contains spaces,
/// it should be enclosed in quotes, e.g. "key=\"value with spaces\"".
///
/// The list of string options to parse
/// A dictionary with the parsed options, where the key is the option name and the value is the option value.
/// If an option was not parsed correctly.
private static Dictionary ParseOptions(IEnumerable options)
{
var result = options
.Select(x => x.Split('='))
.ToDictionary(x => x[0], x => string.Join("=", x.Skip(1)));
// Double check that the options are valid by reconstructing them from the dictionary
foreach (var opt in result.Select(x => $"{x.Key}={x.Value}"))
{
if (!options.Contains(opt))
{
Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "rsync", null,
"The source option '{0}' is not valid. Please check the syntax.", opt);
throw new ArgumentException($"The source option '{opt}' has not been parsed correctly.");
}
}
return result;
}
///
/// Prepares the lists of files to copy, delete and verify.
/// The files to copy are the files that are not in the destination, have a different size or have a more recent modification date.
/// The files to delete are the files that are found in the destination but not found in the source.
/// The files to verify are the files that are found in both the source and the destination, and that have the same size and modification date.
///
/// The source lightweight backend manager.
/// The destination lightweight backend manager.
/// The parsed configuration for the tool.
/// The cancellation token to use for the asynchronous operations.
/// A tuple of Lists each holding the files to copy, delete and verify.
private static async Task<(IEnumerable, IEnumerable, IEnumerable)> PrepareFileLists(LightWeightBackendManager b_src, LightWeightBackendManager b_dst, Config config, CancellationToken token)
{
IEnumerable files_src, files_dst;
using (new Duplicati.Library.Logging.Timer(LOGTAG, "rsync", "Prepare | List source"))
files_src = await b_src.ListAsync(token).ConfigureAwait(false);
using (new Duplicati.Library.Logging.Timer(LOGTAG, "rsync", "Prepare | List destination"))
files_dst = await b_dst.ListAsync(token).ConfigureAwait(false);
// Shortcut for force
if (config.Force)
{
return (files_src, files_dst, []);
}
// Shortcut for empty destination
if (!files_dst.Any())
{
return (files_src, [], []);
}
Dictionary lookup_src, lookup_dst;
using (new Duplicati.Library.Logging.Timer(LOGTAG, "rsync", "Prepare | Build lookup for source and destination"))
{
lookup_src = files_src.ToDictionary(x => x.Name);
lookup_dst = files_dst.ToDictionary(x => x.Name);
}
var to_copy = new List();
var to_delete = new HashSet();
var to_verify = new List();
// Find all of the files in src that are not in dst, where the dst has a different size than src or src a more recent modification date than dst
using (new Duplicati.Library.Logging.Timer(LOGTAG, "rsync", "Prepare | Check the files that are present in source against destination"))
foreach (var f_src in files_src)
{
if (lookup_dst.TryGetValue(f_src.Name, out var f_dst))
{
if (f_src.Size != f_dst.Size || f_src.LastModification > f_dst.LastModification)
{
// The file is different, so we need to copy it
to_copy.Add(f_src);
to_delete.Add(f_dst.Name);
}
else
{
// The file seems to be the same, so we need to verify it if the user wants to
to_verify.Add(f_src);
}
}
else
{
// The file is not in the destination, so we need to copy it
to_copy.Add(f_src);
}
}
// Find all of the files in dst that are not in src
using (new Duplicati.Library.Logging.Timer(LOGTAG, "rsync", "Prepare | Check the files that are present in destination against source"))
foreach (var f_dst in files_dst)
{
if (to_delete.Contains(f_dst.Name))
continue;
if (!lookup_src.ContainsKey(f_dst.Name))
{
to_delete.Add(f_dst.Name);
}
}
List to_delete_lookedup;
using (new Duplicati.Library.Logging.Timer(LOGTAG, "rsync", "Prepare | Lookup the files to delete"))
to_delete_lookedup = [.. to_delete.Select(x => lookup_dst[x])];
return (to_copy, to_delete_lookedup, to_verify);
}
///
/// Renames the files in a backend.
/// The renaming is done by deleting the file and re-uploading it with a new name.
///
/// The lightweight backend manager to issue rename operations to.
/// The files to rename.
/// The parsed configuration for the tool.
/// The cancellation token to use for the asynchronous operations.
/// The number of successful renames.
private static async Task RenameAsync(LightWeightBackendManager bm, IEnumerable files, Config config, CancellationToken token)
{
long successful_renames = 0;
string prefix = $"{System.DateTime.UtcNow:yyyyMMddHHmmss}.old";
using var downloaded = new MemoryStream();
long i = 0, n = files.Count();
var sw = new System.Diagnostics.Stopwatch();
foreach (var f in files)
{
if (config.Progress)
Console.Write($"\rRenaming: {i}/{n}");
Duplicati.Library.Logging.Log.WriteVerboseMessage(LOGTAG, "rsync",
"Renaming {0} to {1}.{0} by calling Rename on {2}",
f.Name, prefix, bm.DisplayName);
try
{
if (config.DryRun)
{
Duplicati.Library.Logging.Log.WriteDryrunMessage(LOGTAG, "rsync",
"Would rename {0} to {1}.{0} by calling Rename on {2}",
f.Name, prefix, bm.DisplayName);
}
else
{
sw.Start();
await bm.RenameAsync(f.Name, $"{prefix}.{f.Name}", token).ConfigureAwait(false);
sw.Stop();
}
successful_renames++;
}
catch (Exception e)
{
Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "rsync", e,
"Error renaming {0}: {1}", f.Name, e.Message);
}
finally
{
// Ensure the timer is stopped
sw.Stop();
}
i++;
}
Duplicati.Library.Logging.Log.WriteProfilingMessage(LOGTAG, "rsync",
"Rename: {0} ms",
TimeSpan.FromMilliseconds(sw.ElapsedMilliseconds));
if (config.Progress)
Console.WriteLine($"\rRenaming: {n}/{n}");
return successful_renames;
}
///
/// Verifies the files in the destination backend.
/// The verification is done by downloading the files from the destination backend and comparing them to the source files.
///
/// The source lightweight backend manager.
/// The destination lightweight backend manager.
/// The files to verify.
/// The parsed configuration for the tool.
/// The cancellation token to use for the asynchronous operations.
/// A list of the files that failed verification.
private static async Task> VerifyAsync(LightWeightBackendManager b_src, LightWeightBackendManager b_dst, IEnumerable files, Config config, CancellationToken token)
{
var errors = new List();
using var s_src = new MemoryStream();
using var s_dst = new MemoryStream();
long i = 0, n = files.Count();
var sw_get = new System.Diagnostics.Stopwatch();
var sw_cmp = new System.Diagnostics.Stopwatch();
foreach (var f in files)
{
if (config.Progress)
Console.Write($"\rVerifying: {i}/{n}");
Duplicati.Library.Logging.Log.WriteVerboseMessage(LOGTAG, "rsync",
"Verifying {0} by downloading and comparing {1} bytes from {2} and {3}",
f.Name,
Duplicati.Library.Utility.Utility.FormatSizeString(s_src.Length),
b_dst.DisplayName, b_src.DisplayName);
try
{
// Get both files
sw_get.Start();
var fs = b_src.GetAsync(f.Name, s_src, token);
var ds = b_dst.GetAsync(f.Name, s_dst, token);
await Task.WhenAll(fs, ds).ConfigureAwait(false);
sw_get.Stop();
// Compare the contents
sw_cmp.Start();
if (s_src.Length != s_dst.Length || !s_src.ToArray().SequenceEqual(s_dst.ToArray()))
{
errors.Add(f);
}
sw_cmp.Stop();
}
catch (Exception e)
{
errors.Add(f);
Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "rsync", e,
"Error during verification of {0}: {1}", f.Name, e.Message);
}
finally
{
// Reset the streams
s_src.SetLength(0);
s_dst.SetLength(0);
// Stop any running timers
sw_get.Stop();
sw_cmp.Stop();
}
i++;
}
Duplicati.Library.Logging.Log.WriteProfilingMessage(LOGTAG, "rsync",
"Verify | Get: {0} ms, Compare: {1} ms",
TimeSpan.FromMilliseconds(sw_get.ElapsedMilliseconds),
TimeSpan.FromMilliseconds(sw_cmp.ElapsedMilliseconds));
if (config.Progress)
Console.WriteLine($"\rVerifying: {n}/{n}");
return errors;
}
}
}