diff --git a/Duplicati/Library/Interface/ResultInterfaces.cs b/Duplicati/Library/Interface/ResultInterfaces.cs
index a583ffba0..87290f175 100644
--- a/Duplicati/Library/Interface/ResultInterfaces.cs
+++ b/Duplicati/Library/Interface/ResultInterfaces.cs
@@ -1,22 +1,22 @@
// 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
+//
+// 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
+//
+// 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
+//
+// 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;
@@ -343,6 +343,11 @@ namespace Duplicati.Library.Interface
IDeleteResults DeleteResults { get; }
IRepairResults RepairResults { get; }
ISetLockResults LockResults { get; }
+
+ ///
+ /// Results from remote synchronization operations to multiple destinations.
+ ///
+ IRemoteSynchronizationResults[] RemoteSynchronizationResults { get; }
}
public interface IRestoreResults : IBasicResults
@@ -380,6 +385,47 @@ namespace Duplicati.Library.Interface
bool Dryrun { get; }
}
+ ///
+ /// Results from a remote synchronization operation to a single destination.
+ ///
+ public interface IRemoteSynchronizationResults : IBasicResults
+ {
+ ///
+ /// The destination URL or identifier.
+ ///
+ string Destination { get; }
+
+ ///
+ /// Number of files deleted from the destination.
+ ///
+ long DeletedFileCount { get; }
+
+ ///
+ /// Number of files renamed at the destination (retention mode).
+ ///
+ long RenamedFileCount { get; }
+
+ ///
+ /// Number of files copied to the destination.
+ ///
+ long CopiedFileCount { get; }
+
+ ///
+ /// Number of files verified at the destination.
+ ///
+ long VerifiedFileCount { get; }
+
+ ///
+ /// Number of files that failed verification.
+ ///
+ long FailedVerificationCount { get; }
+
+ ///
+ /// Total size of files copied in bytes.
+ ///
+ long CopiedFileSize { get; }
+ }
+
public interface ICreateLogDatabaseResults : IBasicResults
{
string TargetPath { get; }
@@ -463,7 +509,7 @@ namespace Duplicati.Library.Interface
}
///
- /// The status of a change in a test entry
+ /// The status of a change in a test entry
///
public enum TestEntryStatus
{
diff --git a/Duplicati/Library/Main/Backend/LightWeightBackendManager.cs b/Duplicati/Library/Main/Backend/LightWeightBackendManager.cs
index 57d7d0437..209f540c2 100644
--- a/Duplicati/Library/Main/Backend/LightWeightBackendManager.cs
+++ b/Duplicati/Library/Main/Backend/LightWeightBackendManager.cs
@@ -26,7 +26,9 @@ namespace Duplicati.Library.Main.Backend
/// The delay between retries, in milliseconds.
/// Whether to automatically create folders if they do not exist.
/// Whether to use exponential backoff for retries.
- public class LightWeightBackendManager(string backendUrl, Dictionary options, int maxRetries = 3, int retryDelay = 1000, bool autoCreateFolders = false, bool retryWithExponentialBackoff = false) : IDisposable
+ /// An optional progress updater for file operations.
+ /// An optional progress updater for backend operations.
+ internal class LightWeightBackendManager(string backendUrl, Dictionary options, int maxRetries = 3, int retryDelay = 1000, bool autoCreateFolders = false, bool retryWithExponentialBackoff = false, IOperationProgressUpdater? progressUpdater = null, IBackendProgressUpdater? backendProgressUpdater = null) : IDisposable
{
private static readonly string LOGTAG = Logging.Log.LogTagFromType();
@@ -115,6 +117,46 @@ namespace Duplicati.Library.Main.Backend
);
}
+ ///
+ /// Gets a file from the remote backend and saves it to a temporary file.
+ /// This method retries the operation with a delay if it fails.
+ ///
+ /// The name of the remote file to get.
+ /// A cancellation token to cancel the operation.
+ /// A task representing the asynchronous get operation. The task result is the path to the temporary file containing the downloaded data.
+ public async Task GetAsync(string remotename, CancellationToken token)
+ {
+ var temp = new TempFile();
+
+ try
+ {
+ await RetryWithDelay(
+ $"Get {remotename} to TempFile",
+ async () =>
+ {
+ using var fileStream = System.IO.File.OpenWrite(temp);
+ using var progressStream = new ProgressReportingStream(fileStream, pg =>
+ {
+ backendProgressUpdater?.UpdateProgress(remotename, pg);
+ progressUpdater?.UpdateFileProgress(pg);
+ });
+ await _streamingBackend!.GetAsync(remotename, progressStream, token).ConfigureAwait(false);
+ _anyDownloaded = true;
+ },
+ null,
+ false,
+ token
+ ).ConfigureAwait(false);
+
+ return temp;
+ }
+ catch
+ {
+ temp.Dispose();
+ throw;
+ }
+ }
+
///
/// Instantiates the backend if it has not been instantiated yet.
/// If the backend is already instantiated, it simply returns.
@@ -187,6 +229,35 @@ namespace Duplicati.Library.Main.Backend
);
}
+ ///
+ /// Puts a file to the remote backend from a temporary file.
+ /// This method retries the operation with a delay if it fails.
+ ///
+ /// The name of the remote file to put.
+ /// The temporary file containing the data to upload.
+ /// A cancellation token to cancel the operation.
+ /// A task representing the asynchronous put operation.
+ public Task PutAsync(string remotename, TempFile temp, CancellationToken token)
+ {
+ return RetryWithDelay(
+ $"Put {remotename}",
+ async () =>
+ {
+ using var fileStream = System.IO.File.OpenRead(temp);
+ using var progressStream = new ProgressReportingStream(fileStream, pg =>
+ {
+ backendProgressUpdater?.UpdateProgress(remotename, pg);
+ progressUpdater?.UpdateFileProgress(pg);
+ });
+ await _streamingBackend!.PutAsync(remotename, progressStream, token).ConfigureAwait(false);
+ _anyUploaded = true;
+ },
+ null,
+ false,
+ token
+ );
+ }
+
///
/// Renames a file in the remote backend.
/// If the backend supports renaming, it uses the RenameAsync method.
@@ -237,6 +308,11 @@ namespace Duplicati.Library.Main.Backend
};
}
+ ///
+ /// Resets the state of the backend manager.
+ /// This method resets the download and upload flags and resets the retry delay to its initial value.
+ ///
+ /// A task representing the asynchronous reset operation.
public async Task Reset()
{
_anyDownloaded = false;
diff --git a/Duplicati/Library/Main/Controller.cs b/Duplicati/Library/Main/Controller.cs
index f6c251caa..161005d86 100644
--- a/Duplicati/Library/Main/Controller.cs
+++ b/Duplicati/Library/Main/Controller.cs
@@ -135,6 +135,10 @@ namespace Duplicati.Library.Main
UsageReporter.Reporter.Report("BACKUP_FILECOUNT", result.ExaminedFiles);
UsageReporter.Reporter.Report("BACKUP_FILESIZE", result.SizeOfExaminedFiles);
UsageReporter.Reporter.Report("BACKUP_DURATION", (long)result.Duration.TotalSeconds);
+
+ using (var h = new Operation.RemoteSynchronizationHandler(m_backendUrl, m_options, result))
+ await h.RunAsync()
+ .ConfigureAwait(false);
});
}
diff --git a/Duplicati/Library/Main/Database/ExtensionMethods.cs b/Duplicati/Library/Main/Database/ExtensionMethods.cs
index de40c3491..85787be9a 100644
--- a/Duplicati/Library/Main/Database/ExtensionMethods.cs
+++ b/Duplicati/Library/Main/Database/ExtensionMethods.cs
@@ -780,8 +780,11 @@ public static partial class ExtensionMethods
else
v = Library.Utility.Utility.FormatInvariantValue(p.Value);
+ paramName = Regex.Escape(paramName);
+ v = v.Replace("$", "$$");
+
// Replace all occurrences of the parameter (with word boundary)
- txt = Regex.Replace(txt, $@"\B{Regex.Escape(paramName)}\b", v, RegexOptions.IgnoreCase);
+ txt = Regex.Replace(txt, $@"\B{paramName}\b", v, RegexOptions.IgnoreCase);
}
return txt;
diff --git a/Duplicati/Library/Main/Duplicati.Library.Main.csproj b/Duplicati/Library/Main/Duplicati.Library.Main.csproj
index 04ba38d6d..52f5a6e83 100644
--- a/Duplicati/Library/Main/Duplicati.Library.Main.csproj
+++ b/Duplicati/Library/Main/Duplicati.Library.Main.csproj
@@ -10,6 +10,8 @@
+
+
diff --git a/Duplicati/Library/Main/Enums.cs b/Duplicati/Library/Main/Enums.cs
index 727399e71..e11d97547 100644
--- a/Duplicati/Library/Main/Enums.cs
+++ b/Duplicati/Library/Main/Enums.cs
@@ -1,22 +1,22 @@
// 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
+//
+// 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
+//
+// 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
+//
+// 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.
namespace Duplicati.Library.Main
@@ -74,7 +74,8 @@ namespace Duplicati.Library.Main
ListFileVersions,
SearchFiles,
SetLock,
- ReadLockInfo
+ ReadLockInfo,
+ RemoteSynchronization
}
///
diff --git a/Duplicati/Library/Main/Operation/RemoteSynchronization/RemoteSynchronizationConfig.cs b/Duplicati/Library/Main/Operation/RemoteSynchronization/RemoteSynchronizationConfig.cs
new file mode 100644
index 000000000..b5e511b2d
--- /dev/null
+++ b/Duplicati/Library/Main/Operation/RemoteSynchronization/RemoteSynchronizationConfig.cs
@@ -0,0 +1,60 @@
+// Copyright (C) 2025, The Duplicati Team
+// https://duplicati.com, hello@duplicati.com
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the "Software"),
+// to deal in the Software without restriction, including without limitation
+// the rights to use, copy, modify, merge, publish, distribute, sublicense,
+// and/or sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System.Collections.Generic;
+
+namespace Duplicati.Library.Main.Operation.RemoteSynchronization;
+
+///
+/// Global configuration for the tool. Should be set after parsing the commandline arguments.
+///
+public sealed record RemoteSynchronizationConfig
+(
+ // 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
+)
+{
+ public override string ToString()
+ {
+ return $"Src: {Library.Utility.Utility.GetUrlWithoutCredentials(Src)}, Dst: {Library.Utility.Utility.GetUrlWithoutCredentials(Dst)}, AutoCreateFolders: {AutoCreateFolders}, BackendRetries: {BackendRetries}, BackendRetryDelay: {BackendRetryDelay}, BackendRetryWithExponentialBackoff: {BackendRetryWithExponentialBackoff}, Confirm: {Confirm}, DryRun: {DryRun}, Force: {Force}, LogFile: {LogFile}, LogLevel: {LogLevel}, ParseArgumentsOnly: {ParseArgumentsOnly}, Progress: {Progress}, Retention: {Retention}, Retry: {Retry}, VerifyContents: {VerifyContents}, VerifyGetAfterPut: {VerifyGetAfterPut}";
+ }
+};
\ No newline at end of file
diff --git a/Duplicati/Library/Main/Operation/RemoteSynchronization/RemoteSynchronizationRunner.cs b/Duplicati/Library/Main/Operation/RemoteSynchronization/RemoteSynchronizationRunner.cs
new file mode 100644
index 000000000..800e56cea
--- /dev/null
+++ b/Duplicati/Library/Main/Operation/RemoteSynchronization/RemoteSynchronizationRunner.cs
@@ -0,0 +1,910 @@
+// Copyright (C) 2025, The Duplicati Team
+// https://duplicati.com, hello@duplicati.com
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the "Software"),
+// to deal in the Software without restriction, including without limitation
+// the rights to use, copy, modify, merge, publish, distribute, sublicense,
+// and/or sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Collections.Generic;
+using System.CommandLine;
+using System.CommandLine.NamingConventionBinder;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Duplicati.Library.Logging;
+using Duplicati.Library.Main.Backend;
+using Duplicati.Library.Utility;
+
+namespace Duplicati.Library.Main.Operation.RemoteSynchronization;
+
+#nullable enable
+
+///
+/// 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, RemoteSynchronizationConfig 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.
+ /// Optional progress updater for reporting file count and transfer progress to the UI.
+ /// Optional backend progress updater for reporting transfer speed to the UI.
+ /// The return code (0 on success).
+ internal static async Task Run(RemoteSynchronizationConfig config, CancellationToken token, IOperationProgressUpdater? progressUpdater = null, IBackendProgressUpdater? backendProgressUpdater = null, IBasicResults? results = null)
+ {
+ // 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;
+ var rsyncResults = results as RemoteSynchronizationResults;
+
+ 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);
+ }
+
+ if (rsyncResults is not null)
+ {
+ using var multi_log_target = new ControllerMultiLogTarget(rsyncResults, log_level_enum, null, null);
+ multi_log_target.AddTarget(console_sink, log_level_enum, null);
+ multi_log_target.AddTarget(log_file_sink, log_level_enum, null);
+
+ // Start the logging scope with both file logging and message capture
+ using var scope = Duplicati.Library.Logging.Log.StartScope(multi_log_target.WriteMessage);
+
+ return await RunCore(config, token, progressUpdater, backendProgressUpdater, rsyncResults).ConfigureAwait(false);
+ }
+ else
+ {
+ 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, progressUpdater, backendProgressUpdater).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ if (rsyncResults is not null)
+ {
+ // Set up logging to capture messages to the results even without a log file
+ using var log_target = new ControllerMultiLogTarget(rsyncResults, log_level_enum, null, null);
+ using var scope = Duplicati.Library.Logging.Log.StartScope(log_target.WriteMessage);
+
+ return await RunCore(config, token, progressUpdater, backendProgressUpdater, rsyncResults).ConfigureAwait(false);
+ }
+ else
+ {
+ return await RunCore(config, token, progressUpdater, backendProgressUpdater).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.
+ /// Optional progress updater for reporting file count and transfer progress to the UI.
+ /// Optional backend progress updater for reporting transfer speed to the UI.
+ /// The return code (0 on success).
+ private static async Task RunCore(RemoteSynchronizationConfig config, CancellationToken token, IOperationProgressUpdater? progressUpdater = null, IBackendProgressUpdater? backendProgressUpdater = null, RemoteSynchronizationResults? results = null)
+ {
+ // 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, progressUpdater: progressUpdater, backendProgressUpdater: backendProgressUpdater);
+ using var b2m = new LightWeightBackendManager(config.Dst, dst_opts, config.BackendRetries, config.BackendRetryDelay, config.BackendRetryWithExponentialBackoff, progressUpdater: progressUpdater, backendProgressUpdater: backendProgressUpdater);
+
+ // 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;
+ }
+ }
+
+ // Report total file count to the progress updater (delete + copy operations)
+ var totalFileCount = to_delete.Count() + to_copy.Count();
+ var totalFileSize = to_copy.Sum(f => Math.Max(f.Size, 0));
+ if (progressUpdater != null)
+ {
+ progressUpdater.UpdatefileCount(totalFileCount, totalFileSize, true);
+ progressUpdater.UpdatefilesProcessed(0, 0);
+ }
+
+ // Delete or rename the files that are not needed
+ long renamed = 0, deleted = 0;
+ var deleteCount = to_delete.Count();
+ if (config.Retention)
+ {
+ renamed = await RenameAsync(b2m, to_delete, config, token, 0, totalFileCount, progressUpdater, backendProgressUpdater).ConfigureAwait(false);
+ Duplicati.Library.Logging.Log.WriteVerboseMessage(LOGTAG, "rsync",
+ "Renamed {0} files in {1}", renamed, b2m.DisplayName);
+ }
+ else
+ {
+ deleted = await DeleteAsync(b2m, to_delete, config, token, 0, totalFileCount, progressUpdater, backendProgressUpdater).ConfigureAwait(false);
+ Duplicati.Library.Logging.Log.WriteVerboseMessage(LOGTAG, "rsync",
+ "Deleted {0} files from {1}", deleted, b2m.DisplayName);
+ }
+
+ // The delete/rename phase now reports progress incrementally, so we don't need a separate update here
+ var deletedOrRenamed = Math.Max(deleted, renamed);
+
+ // Copy the files
+ var (copied, copy_errors) = await CopyAsync(b1m, b2m, to_copy, config, deletedOrRenamed, totalFileCount, token, progressUpdater, backendProgressUpdater).ConfigureAwait(false);
+ Duplicati.Library.Logging.Log.WriteVerboseMessage(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.WriteVerboseMessage(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, deletedOrRenamed, totalFileCount, token, progressUpdater, backendProgressUpdater).ConfigureAwait(false);
+ Duplicati.Library.Logging.Log.WriteVerboseMessage(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());
+ results?.DeletedFileCount = deleted;
+ results?.RenamedFileCount = renamed;
+ results?.CopiedFileCount = copied;
+ results?.VerifiedFileCount = verified;
+ results?.FailedVerificationCount = failed_verify;
+ results?.CopiedFileSize = totalFileSize;
+ return copy_errors.Count();
+ }
+ }
+
+ // Results reporting
+ if (results is not null)
+ {
+ 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");
+
+ results?.DeletedFileCount = deleted;
+ results?.RenamedFileCount = renamed;
+ results?.CopiedFileCount = copied;
+ results?.VerifiedFileCount = verified;
+ results?.FailedVerificationCount = failed_verify;
+ results?.CopiedFileSize = totalFileSize;
+
+ 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
+
+ ///
+ /// 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 number of files already processed before this copy phase (e.g., from delete/rename), used for progress reporting.
+ /// The total number of files to be processed in the entire operation (delete + copy), used for progress reporting.
+ /// The cancellation token to use for the asynchronous operations.
+ /// Optional progress updater for reporting file-level progress to the UI.
+ /// Optional backend progress updater for reporting transfer speed to the UI.
+ /// 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, RemoteSynchronizationConfig config, long filesProcessedOffset, long totalFiles, CancellationToken token, IOperationProgressUpdater? progressUpdater = null, IBackendProgressUpdater? backendProgressUpdater = null)
+ {
+ long successful_copies = 0;
+ long sizeProcessed = 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);
+
+ try
+ {
+ // Report downloading current file with real-time progress
+ var fileSize = Math.Max(f.Size, 0);
+ progressUpdater?.StartFile($"Downloading {f.Name}", fileSize);
+ backendProgressUpdater?.StartAction(BackendActionType.Get, f.Name, fileSize);
+
+ sw_get_src.Start();
+ using var tmp_src = await b_src.GetAsync(f.Name, token).ConfigureAwait(false);
+ sw_get_src.Stop();
+
+ backendProgressUpdater?.EndAction(BackendActionType.Get, f.Name);
+
+ var s_src_length = SystemIO.IO_OS.FileLength(tmp_src);
+
+ 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
+ {
+ // Compute hash BEFORE uploading, so we don't need to seek back after PutAsync
+ byte[]? sourceHash = null;
+ if (config.VerifyGetAfterPut)
+ {
+ using var hasher = HashFactory.CreateHasher("SHA256");
+ using var s = SystemIO.IO_OS.FileOpenRead(tmp_src);
+ sourceHash = hasher.ComputeHash(s);
+ }
+
+ // Report uploading current file with real-time progress
+ progressUpdater?.StartFile($"Uploading {f.Name}", s_src_length);
+ backendProgressUpdater?.StartAction(BackendActionType.Put, f.Name, s_src_length);
+
+ sw_put_dst.Start();
+ await b_dst.PutAsync(f.Name, tmp_src, token).ConfigureAwait(false);
+ sw_put_dst.Stop();
+
+ backendProgressUpdater?.EndAction(BackendActionType.Put, f.Name);
+
+ if (config.VerifyGetAfterPut)
+ {
+ 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 (sourceHash != null && Convert.ToBase64String(sourceHash) != 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++;
+ sizeProcessed += s_src_length;
+
+ // Report overall progress: files processed so far (offset + current index)
+ var total_progress = filesProcessedOffset + successful_copies;
+ progressUpdater?.UpdatefilesProcessed(total_progress, sizeProcessed);
+ progressUpdater?.UpdateProgress(totalFiles > 0 ? (float)total_progress / totalFiles : 1.0f);
+ }
+ 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 files already processed before this delete phase, used for overall progress reporting.
+ /// The total number of files to be processed in the entire operation (delete + copy), used for overall progress reporting.
+ /// Optional progress updater for reporting file-level progress to the UI.
+ /// Optional backend progress updater for reporting delete speed to the UI.
+ /// The number of successful deletions.
+ private static async Task DeleteAsync(LightWeightBackendManager b, IEnumerable files, RemoteSynchronizationConfig config, CancellationToken token, long filesProcessedOffset, long totalFiles, IOperationProgressUpdater? progressUpdater = null, IBackendProgressUpdater? backendProgressUpdater = null)
+ {
+ 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
+ {
+ // Report delete operation with progress
+ var fileSize = Math.Max(f.Size, 0);
+ progressUpdater?.StartFile($"Deleting {f.Name}", fileSize);
+ backendProgressUpdater?.StartAction(BackendActionType.Delete, f.Name, fileSize);
+
+ if (config.DryRun)
+ {
+ Duplicati.Library.Logging.Log.WriteDryrunMessage(LOGTAG, "rsync",
+ "Would delete {0} from {1}", f.Name, b.DisplayName);
+ }
+ else
+ {
+ using (var timer_delete = new Duplicati.Library.Logging.Timer(LOGTAG, "rsync", $"Delete {f.Name}"))
+ await b.DeleteAsync(f.Name, token).ConfigureAwait(false);
+ }
+ successful_deletes++;
+
+ backendProgressUpdater?.EndAction(BackendActionType.Delete, f.Name);
+
+ // Report overall progress: files processed so far (with offset for overall progress)
+ var total_progress = filesProcessedOffset + successful_deletes;
+ progressUpdater?.UpdatefilesProcessed(total_progress, 0);
+ progressUpdater?.UpdateProgress(totalFiles > 0 ? (float)total_progress / totalFiles : 1.0f);
+ }
+ catch (Exception e)
+ {
+ Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "rsync", e,
+ "Error deleting {0}: {1}", f.Name, e.Message);
+ }
+ finally
+ {
+ 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, RemoteSynchronizationConfig 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 files already processed before this rename phase, used for overall progress reporting.
+ /// The total number of files to be processed in the entire operation (delete + copy), used for progress reporting.
+ /// Optional progress updater for reporting file-level progress to the UI.
+ /// Optional backend progress updater for reporting transfer speed to the UI.
+ /// The number of successful renames.
+ private static async Task RenameAsync(LightWeightBackendManager bm, IEnumerable files, RemoteSynchronizationConfig config, CancellationToken token, long filesProcessedOffset, long totalFiles, IOperationProgressUpdater? progressUpdater = null, IBackendProgressUpdater? backendProgressUpdater = null)
+ {
+ 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
+ {
+ // Report rename operation with progress
+ var fileSize = Math.Max(f.Size, 0);
+ progressUpdater?.StartFile($"Renaming {f.Name}", fileSize);
+ // Use Delete action type since rename typically involves delete operation
+ backendProgressUpdater?.StartAction(BackendActionType.Delete, f.Name, fileSize);
+
+ 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++;
+
+ backendProgressUpdater?.EndAction(BackendActionType.Delete, f.Name);
+
+ // Report overall progress: files processed so far (with offset for overall progress)
+ var total_progress = filesProcessedOffset + successful_renames;
+ progressUpdater?.UpdatefilesProcessed(total_progress, 0);
+ progressUpdater?.UpdateProgress(totalFiles > 0 ? (float)total_progress / totalFiles : 1.0f);
+ }
+ 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, RemoteSynchronizationConfig 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;
+ }
+
+}
\ No newline at end of file
diff --git a/Duplicati/Library/Main/Operation/RemoteSynchronization/Strings.cs b/Duplicati/Library/Main/Operation/RemoteSynchronization/Strings.cs
new file mode 100644
index 000000000..3972ac08c
--- /dev/null
+++ b/Duplicati/Library/Main/Operation/RemoteSynchronization/Strings.cs
@@ -0,0 +1,165 @@
+// 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.Localization.Short;
+
+namespace Duplicati.Library.Main.Operation.RemoteSynchronization;
+
+///
+/// Localized strings for the Remote Synchronization tool.
+///
+public static class Strings
+{
+ ///
+ /// Root command description.
+ ///
+ public static string RootCommandDescription { get { return LC.L(@"Remote Synchronization Tool
+
+This tool synchronizes two remote backends. The tool assumes that the intent is
+to have the destination match the source.
+
+If the destination has files that are not in the source, they will be deleted
+(or renamed if the retention option is set).
+
+If the destination has files that are also present in the source, but the files
+differ in size, or if the source files have a newer (more recent) timestamp,
+the destination files will be overwritten by the source files. Given that some
+backends do not allow for metadata or timestamp modification, and that the tool
+is run after backup, the destination files should always have a timestamp that
+is newer (or the same if run promptly) compared to the source files.
+
+If the force option is set, the destination will be overwritten by the source,
+regardless of the state of the files. It will also skip the initial comparison,
+and delete (or rename) all files in the destination.
+
+If the verify option is set, the files will be downloaded and compared after
+uploading to ensure that the files are correct. Files that already exist in the
+destination will be verified before being overwritten (if they seemingly match).
+"); } }
+
+ ///
+ /// Description of the handler
+ ///
+ public static string Description { get { return LC.L(@"This module runs remote synchronization after a successful backup to create a secondary copy (3-2-1)."); } }
+
+ public static string DisplayName { get { return LC.L(@"Remote synchronization (3-2-1)"); } }
+
+ ///
+ /// The source backend string argument description.
+ ///
+ public static string SourceBackendDescription { get { return LC.L("The source backend string"); } }
+
+ ///
+ /// The destination backend string argument description.
+ ///
+ public static string DestinationBackendDescription { get { return LC.L("The destination backend string"); } }
+
+ ///
+ /// Auto-create folders option description.
+ ///
+ public static string AutoCreateFoldersDescription { get { return LC.L("Automatically create folders in the destination backend if they do not exist"); } }
+
+ ///
+ /// Backend retries option description.
+ ///
+ public static string BackendRetriesDescription { get { return LC.L("Number of times to recreate a backend on backend errors"); } }
+
+ ///
+ /// Backend retry delay option description.
+ ///
+ public static string BackendRetryDelayDescription { get { return LC.L("Delay in milliseconds between backend retries"); } }
+
+ ///
+ /// Backend retry with exponential backoff option description.
+ ///
+ public static string BackendRetryWithExponentialBackoffDescription { get { return LC.L("Use exponential backoff for backend retries, multiplying the delay by two for each failure."); } }
+
+ ///
+ /// Confirm option description.
+ ///
+ public static string ConfirmDescription { get { return LC.L("Automatically confirm the operation"); } }
+
+ ///
+ /// Dry-run option description.
+ ///
+ public static string DryRunDescription { get { return LC.L("Do not actually write or delete files. If not set here, the global options will be checked"); } }
+
+ ///
+ /// Destination options option description.
+ ///
+ public static string DstOptionsDescription { get { return LC.L("Options for the destination backend. Each option is a key-value pair separated by an equals sign, e.g. --dst-options key1=value1 key2=value2 [default: empty]"); } }
+
+ ///
+ /// Force option description.
+ ///
+ public static string ForceDescription { get { return LC.L("Force the synchronization"); } }
+
+ ///
+ /// Global options option description.
+ ///
+ public static string GlobalOptionsDescription { get { return LC.L("Global options all backends. May be overridden by backend specific options (src-options, dst-options). Each option is a key-value pair separated by an equals sign, e.g. --global-options key1=value1 key2=value2 [default: empty]"); } }
+
+ ///
+ /// Log file option description.
+ ///
+ public static string LogFileDescription { get { return LC.L("The log file to write to. If not set here, global options will be checked [default: \"\"]"); } }
+
+ ///
+ /// Log level option description.
+ ///
+ public static string LogLevelDescription { get { return LC.L("The log level to use. If not set here, global options will be checked"); } }
+
+ ///
+ /// Parse arguments only option description.
+ ///
+ public static string ParseArgumentsOnlyDescription { get { return LC.L("Only parse the arguments and then exit"); } }
+
+ ///
+ /// Progress option description.
+ ///
+ public static string ProgressDescription { get { return LC.L("Print progress to STDOUT"); } }
+
+ ///
+ /// Retention option description.
+ ///
+ public static string RetentionDescription { get { return LC.L("Toggles whether to keep old files. Any deletes will be renames instead"); } }
+
+ ///
+ /// Retry option description.
+ ///
+ public static string RetryDescription { get { return LC.L("Number of times to retry on errors"); } }
+
+ ///
+ /// Source options option description.
+ ///
+ public static string SrcOptionsDescription { get { return LC.L("Options for the source backend. Each option is a key-value pair separated by an equals sign, e.g. --src-options key1=value1 key2=value2 [default: empty]"); } }
+
+ ///
+ /// Verify contents option description.
+ ///
+ public static string VerifyContentsDescription { get { return LC.L("Verify the contents of the files to decide whether the pre-existing destination files should be overwritten"); } }
+
+ ///
+ /// Verify get after put option description.
+ ///
+ public static string VerifyGetAfterPutDescription { get { return LC.L("Verify the files after uploading them to ensure that they were uploaded correctly"); } }
+
+}
\ No newline at end of file
diff --git a/Duplicati/Library/Modules/Builtin/RemoteSynchronizationModule.cs b/Duplicati/Library/Main/Operation/RemoteSynchronizationHandler.cs
similarity index 79%
rename from Duplicati/Library/Modules/Builtin/RemoteSynchronizationModule.cs
rename to Duplicati/Library/Main/Operation/RemoteSynchronizationHandler.cs
index 99384b8a3..e21799f47 100644
--- a/Duplicati/Library/Modules/Builtin/RemoteSynchronizationModule.cs
+++ b/Duplicati/Library/Main/Operation/RemoteSynchronizationHandler.cs
@@ -27,15 +27,16 @@ using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
+using System.Threading.Tasks;
using Duplicati.Library.Interface;
using Duplicati.Library.Main.Database;
+using Duplicati.Library.Main.Operation.RemoteSynchronization;
using Duplicati.Library.SQLiteHelper;
using Duplicati.Library.Utility;
-using RemoteSynchronization;
#nullable enable
-namespace Duplicati.Library.Modules.Builtin;
+namespace Duplicati.Library.Main.Operation;
///
/// Trigger modes for remote synchronization.
@@ -60,12 +61,15 @@ public enum RemoteSyncTriggerMode
/// Configuration for a single remote synchronization destination.
///
public record RemoteSyncDestinationConfig(
- RemoteSynchronization.Config Config,
+ RemoteSynchronizationConfig Config,
RemoteSyncTriggerMode Mode = RemoteSyncTriggerMode.Inline,
TimeSpan? Interval = null,
int? Count = null
);
+///
+/// Raw configuration for a remote synchronization destination, as deserialized from JSON.
+///
public record RemoteSyncDestinationConfigRaw(
bool AutoCreateFolders = true,
int BackendRetries = 3,
@@ -93,6 +97,9 @@ public record RemoteSyncDestinationConfigRaw(
public int? Count { get; init; }
};
+///
+/// Top-level configuration for remote synchronization, containing the destinations array and global settings.
+///
public record TopLevelRemoteSyncConfig(
bool SyncOnWarnings = true
)
@@ -103,76 +110,52 @@ public record TopLevelRemoteSyncConfig(
///
/// Module for synchronizing backup data to remote destinations after a successful backup operation.
///
-public class RemoteSynchronizationModule : IGenericCallbackModule
+internal class RemoteSynchronizationHandler : IDisposable
{
- private static readonly string LOGTAG = Logging.Log.LogTagFromType();
-
- private const string OPTION_JSON_CONFIG = "remote-sync-json-config";
+ private static readonly string LOGTAG = Logging.Log.LogTagFromType();
private string? m_dbpath;
+ private bool m_enabled = false;
private List m_destinations = [];
- private bool m_enabled;
- private string? m_operationName;
- private string? m_source;
+ private string m_source;
private bool m_syncOnWarnings = true;
-
- ///
- /// Gets the key identifier for this module.
- ///
- public string Key => "remotesync";
- ///
- /// Gets the display name for this module.
- ///
- public string DisplayName => Strings.RemoteSynchronization.DisplayName;
- ///
- /// Gets the description of this module.
- ///
- public string Description => Strings.RemoteSynchronization.Description;
- ///
- /// Gets whether this module should be loaded by default.
- ///
- public bool LoadAsDefault => true;
-
- ///
- /// Gets the list of supported command line arguments.
- ///
- public IList SupportedCommands =>
- [
- new CommandLineArgument(OPTION_JSON_CONFIG, CommandLineArgument.ArgumentType.String, "JSON configuration for remote synchronization", "JSON string or file path containing remote synchronization configuration"),
- ];
+ private BackupResults m_results;
///
/// Configures the module with the provided command line options.
///
- /// The command line options dictionary.
- public void Configure(IDictionary commandlineOptions)
+ /// The URL of the source backend.
+ /// The command line options dictionary.
+ /// The backup results object, used to determine whether to run the synchronization and to report results.
+ public RemoteSynchronizationHandler(string backendUrl, Options options, BackupResults results)
{
+ m_results = results;
+ m_source = backendUrl;
+
// Default is no valid JSON config provided, which disables the module
m_enabled = false;
m_destinations = [];
-
- if (commandlineOptions.TryGetValue("dbpath", out var dbpath))
- m_dbpath = dbpath;
+ m_dbpath = options.Dbpath;
// Check if JSON config is provided
- if (commandlineOptions.TryGetValue(OPTION_JSON_CONFIG, out var jsonConfigStr) && !string.IsNullOrWhiteSpace(jsonConfigStr))
+ if (!string.IsNullOrWhiteSpace(options.RemoteSyncJsonConfig))
{
string jsonContent;
- if (jsonConfigStr.TrimStart().StartsWith("{"))
+ if (options.RemoteSyncJsonConfig.TrimStart().StartsWith("{"))
{
// It's a JSON string
- jsonContent = jsonConfigStr;
+ jsonContent = options.RemoteSyncJsonConfig;
}
else
{
// It's a file path
try
{
- jsonContent = File.ReadAllText(jsonConfigStr);
+ jsonContent = File.ReadAllText(options.RemoteSyncJsonConfig);
}
catch (Exception ex)
{
- Logging.Log.WriteErrorMessage(LOGTAG, "RemoteSyncJsonFileReadError", ex, "Failed to read JSON configuration file '{0}': {1}", jsonConfigStr, ex.Message);
+ Logging.Log.WriteErrorMessage(LOGTAG, "RemoteSyncJsonFileReadError", ex, "Failed to read JSON configuration file '{0}': {1}", options.RemoteSyncJsonConfig, ex.Message);
return;
}
}
@@ -198,9 +181,7 @@ public class RemoteSynchronizationModule : IGenericCallbackModule
{
foreach (var destination in toplevel.Destinations)
{
- var loglevel = string.IsNullOrWhiteSpace(destination.LogLevel) ?
- (commandlineOptions.TryGetValue("log-file-log-level", out var logLevel) ? logLevel : "Information")
- : destination.LogLevel;
+ var loglevel = options.LogFileLoglevel;
var mode = !string.IsNullOrWhiteSpace(destination.Mode) && Enum.TryParse(destination.Mode, true, out var parsedMode) ? parsedMode : RemoteSyncTriggerMode.Inline;
TimeSpan? interval_parsed;
@@ -252,12 +233,12 @@ public class RemoteSynchronizationModule : IGenericCallbackModule
Force: destination.Force,
GlobalOptions: destination.GlobalOptions,
LogFile: destination.LogFile,
- LogLevel: loglevel,
+ LogLevel: loglevel.ToString(),
ParseArgumentsOnly: destination.ParseArgumentsOnly,
Progress: destination.Progress,
Retention: destination.Retention,
Retry: destination.Retry,
- SrcOptions: [.. commandlineOptions.Select((k, v) => $"{k}={v}")],
+ SrcOptions: [.. options.RawOptions.Select((k, v) => $"{k}={v}")],
VerifyContents: destination.VerifyContents,
VerifyGetAfterPut: destination.VerifyGetAfterPut
),
@@ -284,49 +265,28 @@ public class RemoteSynchronizationModule : IGenericCallbackModule
}
- ///
- /// Called when an operation starts.
- ///
- /// The name of the operation.
- /// The remote URL.
- /// The local paths.
- public void OnStart(string operationname, ref string remoteurl, ref string[] localpath)
+ ///
+ public void Dispose()
{
- if (!m_enabled)
- return;
-
- m_operationName = operationname;
-
- if (string.IsNullOrWhiteSpace(m_source))
- m_source = remoteurl;
+ // Nothing to dispose currently, but implement IDisposable in case we need it in the future
}
///
- /// Called when an operation finishes.
+ /// Runs the remote synchronization operations based on the configured destinations and the results of the backup operation.
+ /// The synchronization will only be triggered for destinations whose conditions are met (e.g. interval has passed, backup count reached, etc.).
///
- /// The results of the operation.
- /// Any exception that occurred during the operation.
- public void OnFinish(IBasicResults result, Exception exception)
+ public async Task RunAsync()
{
if (!m_enabled)
return;
- if (!string.Equals(m_operationName, "Backup", StringComparison.OrdinalIgnoreCase))
- return;
-
- if (exception != null)
- {
- Logging.Log.WriteWarningMessage(LOGTAG, "RemoteSyncSkipped", exception, "Remote synchronization skipped due to operation failure.");
- return;
- }
-
- if (result != null && (result.ParsedResult == ParsedResultType.Error || result.ParsedResult == ParsedResultType.Fatal))
+ if (m_results.ParsedResult == ParsedResultType.Error || m_results.ParsedResult == ParsedResultType.Fatal)
{
Logging.Log.WriteWarningMessage(LOGTAG, "RemoteSyncSkipped", null, "Remote synchronization skipped because backup reported errors.");
return;
}
- if (result != null && result.ParsedResult == ParsedResultType.Warning && !m_syncOnWarnings)
+ if (m_results.ParsedResult == ParsedResultType.Warning && !m_syncOnWarnings)
{
Logging.Log.WriteInformationMessage(LOGTAG, "RemoteSyncSkipped", "Remote synchronization skipped because backup reported warnings and sync on warnings is disabled.");
return;
@@ -344,6 +304,9 @@ public class RemoteSynchronizationModule : IGenericCallbackModule
return;
}
+ // Store remote sync results to be added to backup results
+ var syncResultsList = new List();
+
for (int i = 0; i < m_destinations.Count; i++)
{
var dest = m_destinations[i];
@@ -358,19 +321,41 @@ public class RemoteSynchronizationModule : IGenericCallbackModule
try
{
+ // Update progress to show sync is in progress
+ m_results.OperationProgressUpdater.UpdatePhase(OperationPhase.Backup_RemoteSynchronization);
+ m_results.OperationProgressUpdater.UpdateRemoteSyncDestination(i + 1, m_destinations.Count);
+ m_results.OperationProgressUpdater.UpdateProgress(0);
+
var config = dest.Config with { Src = m_source! };
- var exitCode = RemoteSynchronizationRunner.Run(config, CancellationToken.None).ConfigureAwait(false).GetAwaiter().GetResult();
+ var date_start = DateTime.UtcNow;
+ var syncResult = new RemoteSynchronizationResults
+ {
+ Destination = Duplicati.Library.Utility.Utility.GetUrlWithoutCredentials(config.Dst),
+ };
+ var exitCode = RemoteSynchronizationRunner.Run(config, CancellationToken.None, m_results.OperationProgressUpdater, m_results.BackendProgressUpdater, syncResult).ConfigureAwait(false).GetAwaiter().GetResult();
+ var date_end = DateTime.UtcNow;
+ syncResult.BeginTime = date_start;
+ syncResult.EndTime = date_end;
+ syncResultsList.Add(syncResult);
if (exitCode == 0)
RecordSyncOperation(i);
else
+ {
+ // Error is captured into the result's Errors collection via the active log scope
Logging.Log.WriteErrorMessage(LOGTAG, "RemoteSyncFailed", null, "Remote synchronization to {0} failed with exit code {1}.", dest, exitCode);
+ }
}
catch (Exception ex)
{
+ // Error is captured into the result's Errors collection via the active log scope
Logging.Log.WriteErrorMessage(LOGTAG, "RemoteSyncFailed", ex, "Remote synchronization to {0} failed: {1}", dest, ex.Message);
}
}
+
+ // Add remote synchronization results to the backup results for reporting
+ if (syncResultsList.Count > 0)
+ m_results.RemoteSynchronizationResults = [.. syncResultsList];
}
///
@@ -406,7 +391,7 @@ public class RemoteSynchronizationModule : IGenericCallbackModule
var lastSync = cmd.ExecuteScalar();
if (lastSync is null)
return true;
- var lastSyncTime = Utility.Utility.EPOCH.AddSeconds((long)lastSync);
+ var lastSyncTime = Duplicati.Library.Utility.Utility.EPOCH.AddSeconds((long)lastSync);
var now = DateTime.UtcNow;
return (now - lastSyncTime) >= dest.Interval;
}
@@ -477,7 +462,7 @@ public class RemoteSynchronizationModule : IGenericCallbackModule
)";
cmd.SetTransaction(transaction);
cmd.AddNamedParameter("@description", $"Rsync {index}");
- cmd.AddNamedParameter("@timestamp", Utility.Utility.NormalizeDateTimeToEpochSeconds(DateTime.UtcNow));
+ cmd.AddNamedParameter("@timestamp", Duplicati.Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(DateTime.UtcNow));
cmd.ExecuteNonQuery();
transaction.Commit();
}
diff --git a/Duplicati/Library/Main/Operation/Restore/VolumeManager.cs b/Duplicati/Library/Main/Operation/Restore/VolumeManager.cs
index 663e68aee..6bd6e260f 100644
--- a/Duplicati/Library/Main/Operation/Restore/VolumeManager.cs
+++ b/Duplicati/Library/Main/Operation/Restore/VolumeManager.cs
@@ -25,6 +25,7 @@ using Duplicati.Library.Utility;
using System;
using System.Collections.Generic;
using System.Diagnostics;
+using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -45,6 +46,11 @@ namespace Duplicati.Library.Main.Operation.Restore
///
private static readonly string LOGTAG = Logging.Log.LogTagFromType();
+ ///
+ /// Number of disk-pressure evictions that must occur before a mid-run warning is emitted.
+ ///
+ private const int CACHE_PRESSURE_WARNING_THRESHOLD = 5;
+
///
/// Helper class to read from either of two channels.
/// This is a workaround for the fact that CoCoL seems to deadlock on ReadFroAnyAsync
@@ -108,15 +114,29 @@ namespace Duplicati.Library.Main.Operation.Restore
async self =>
{
// The maximum number of volumes to have in cache at once. If this is exceeded, we'll try to evict the least recently used volume that is not actively in use.
+ // -1 = unlimited (disk-space-aware), 0 = disabled, >0 = hard cap in bytes.
long cache_max = options.RestoreVolumeCacheHint;
+ // Minimum free space (bytes) to maintain in temp dir — used only in unlimited mode.
+ long cache_min_free = options.RestoreVolumeCacheMinFree;
+ // Temp directory path used for DriveInfo queries in unlimited mode.
+ string temp_dir = options.TempDir;
// Cache of volume readers.
Dictionary cache = [];
// Current size of the cache in bytes.
long cache_size = 0;
+ // Maximum cache size reached during this restore run.
+ long cache_size_max_consumed = 0;
// List of which volume was accessed last. Used for cache eviction.
List cache_last_touched = [];
// Dictionary to keep track of active downloads. Used for grouping requests to the same volume.
Dictionary> in_flight_downloads = [];
+ // Disk-pressure counters (unlimited cache mode only).
+ HashSet previously_evicted_volume_ids = [];
+ HashSet all_accessed_volume_ids = [];
+ long disk_pressure_evictions = 0;
+ long disk_pressure_redownloads = 0;
+ long total_volumes_accessed = 0;
+ bool cache_exhausted_warned = false;
Stopwatch? sw_cache_set = options.InternalProfiling ? new() : null;
Stopwatch? sw_cache_evict = options.InternalProfiling ? new() : null;
@@ -126,6 +146,14 @@ namespace Duplicati.Library.Main.Operation.Restore
Stopwatch? sw_request = options.InternalProfiling ? new() : null;
Stopwatch? sw_wakeup = options.InternalProfiling ? new() : null;
+ void handle_add(long volume_id, VolumeWrapper volume)
+ {
+ cache[volume_id] = volume;
+ cache_size += volume.Size;
+ cache_size_max_consumed = Math.Max(cache_size_max_consumed, cache_size);
+ cache_last_touched.Add(volume_id);
+ }
+
void handle_evict(long volume_id)
{
Logging.Log.WriteExplicitMessage(LOGTAG, "VolumeRequest", "Evicting volume {0} from cache", volume_id);
@@ -140,17 +168,19 @@ namespace Duplicati.Library.Main.Operation.Restore
sw_cache_evict?.Stop();
}
- void evict_lru()
+ long evict_lru()
{
sw_cache_lru?.Start();
+ long volume_id = -1;
if (cache_last_touched.Count > 0)
{
// Pop the last element of cache_last_touched
- var volume_id = cache_last_touched[0];
- cache_last_touched.RemoveAt(0);
+ volume_id = cache_last_touched[0];
handle_evict(volume_id);
}
sw_cache_lru?.Stop();
+
+ return volume_id;
}
await results.TaskControl.ProgressRendevouz().ConfigureAwait(false);
@@ -180,12 +210,18 @@ namespace Duplicati.Library.Main.Operation.Restore
sw_request?.Start();
if (cache.TryGetValue(request.VolumeID, out var volume))
{
- cache_last_touched.Remove(request.VolumeID);
- cache_last_touched.Add(request.VolumeID);
Logging.Log.WriteExplicitMessage(LOGTAG, "VolumeRequest", "Block {0} found in cache", request.BlockID);
+
+ if (cache_max != 0)
+ {
+ // Move the accessed volume to the end of the LRU list.
+ cache_last_touched.Remove(request.VolumeID);
+ cache_last_touched.Add(request.VolumeID);
+ }
volume.Reference();
- await self.DecompressRequest.WriteAsync((request, volume)).ConfigureAwait(false);
+
Logging.Log.WriteExplicitMessage(LOGTAG, "VolumeRequest", "Requesting decompression of block {0} from cached volume {1}", request.BlockID, request.VolumeID);
+ await self.DecompressRequest.WriteAsync((request, volume)).ConfigureAwait(false);
}
else
{
@@ -196,6 +232,10 @@ namespace Duplicati.Library.Main.Operation.Restore
}
else
{
+ if (all_accessed_volume_ids.Add(request.VolumeID))
+ total_volumes_accessed++;
+ if (previously_evicted_volume_ids.Contains(request.VolumeID))
+ disk_pressure_redownloads++;
await self.DownloadRequest.WriteAsync(request.VolumeID).ConfigureAwait(false);
in_flight_downloads[request.VolumeID] = [request];
}
@@ -211,31 +251,56 @@ namespace Duplicati.Library.Main.Operation.Restore
{
sw_cache_set?.Start();
volume.Reference(in_flight_downloads[volume_id].Count);
- if (cache_max > 0)
+ if (cache_max == 0)
{
- // Check if adding another volume would exceed cache limits.
+ // Caching disabled — dispose immediately.
+ Logging.Log.WriteExplicitMessage(LOGTAG, "VolumeRequest", "Not caching volume {0} (caching disabled)", volume_id);
+ volume.Dispose();
+ }
+ else if (cache_max > 0)
+ {
+ // Hard-cap LRU: evict until the new volume fits.
while (cache_size > 0 && (cache_size + volume.Size) > cache_max)
{
// TODO switch based of the eviction strategy.
// fifo / lifo based on both when they were downloaded and when they were used
// random
// Heuristic based of accesses and recency
+
// Cache would overflow if we request another; we have to evict something, or store the request for later.
Logging.Log.WriteExplicitMessage(LOGTAG, "VolumeRequest", "Cache full ({0} + {1} > {2}), evicting LRU", cache_size, volume.Size, cache_max);
evict_lru();
}
+
Logging.Log.WriteExplicitMessage(LOGTAG, "VolumeRequest", "Caching volume {0} ({1} + {2} <= {3})", volume_id, cache_size, volume.Size, cache_max);
- cache[volume_id] = volume;
- cache_size += volume.Size;
+ handle_add(volume_id, volume);
}
else
{
- Logging.Log.WriteExplicitMessage(LOGTAG, "VolumeRequest", "Not caching volume {0} ({1} + {2} > {3})", volume_id, cache_size, volume.Size, cache_max);
- volume.Dispose();
+ // Unlimited mode (cache_max < 0): evict LRU while free space is below the minimum.
+ var available_free_space = new DriveInfo(temp_dir).AvailableFreeSpace;
+ while (available_free_space < cache_min_free && cache_size > 0)
+ {
+ var evict_id = evict_lru();
+ previously_evicted_volume_ids.Add(evict_id);
+ disk_pressure_evictions++;
+ if (disk_pressure_evictions == CACHE_PRESSURE_WARNING_THRESHOLD)
+ Logging.Log.WriteWarningMessage(LOGTAG, "CachePressure", null, "Restore volume cache has begun evicting cached volumes due to low disk space in '{0}'. Restore performance may be degraded.", temp_dir);
+ available_free_space = new DriveInfo(temp_dir).AvailableFreeSpace;
+ }
+
+ if (!cache_exhausted_warned && cache_size == 0 && available_free_space < cache_min_free)
+ {
+ cache_exhausted_warned = true;
+ Logging.Log.WriteWarningMessage(LOGTAG, "CacheExhausted", null, "Restore volume cache is empty but disk space in '{0}' is still below the configured minimum. Performance impact is likely.", temp_dir);
+ }
+
+ Logging.Log.WriteExplicitMessage(LOGTAG, "VolumeRequest", "Caching volume {0} in unlimited mode (free space: {1})", volume_id, available_free_space);
+ handle_add(volume_id, volume);
}
- cache_last_touched.Add(volume_id);
sw_cache_set?.Stop();
sw_wakeup?.Start();
+
foreach (var request in in_flight_downloads[volume_id])
{
// Request the decompressions
@@ -255,9 +320,14 @@ namespace Duplicati.Library.Main.Operation.Restore
{
Logging.Log.WriteVerboseMessage(LOGTAG, "RetiredProcess", null, "Volume manager retired");
+ results.CachePressureEvictions = disk_pressure_evictions;
+ results.CachePressureRedownloads = disk_pressure_redownloads;
+ results.TotalVolumesAccessed = total_volumes_accessed;
+
if (options.InternalProfiling)
{
- Logging.Log.WriteProfilingMessage(LOGTAG, "InternalTimings", $"CacheSet: {sw_cache_set?.ElapsedMilliseconds}ms, CacheEvict: {sw_cache_evict?.ElapsedMilliseconds}ms, Query: {sw_query?.ElapsedMilliseconds}ms, Backend: {sw_backend?.ElapsedMilliseconds}ms, Request: {sw_request?.ElapsedMilliseconds}ms, Wakeup: {sw_wakeup?.ElapsedMilliseconds}ms");
+ Logging.Log.WriteProfilingMessage(LOGTAG, "InternalTimings", $"CacheSet: {sw_cache_set?.ElapsedMilliseconds}ms, CacheEvict: {sw_cache_evict?.ElapsedMilliseconds}ms, CacheLRU: {sw_cache_lru?.ElapsedMilliseconds}ms, Query: {sw_query?.ElapsedMilliseconds}ms, Backend: {sw_backend?.ElapsedMilliseconds}ms, Request: {sw_request?.ElapsedMilliseconds}ms, Wakeup: {sw_wakeup?.ElapsedMilliseconds}ms");
+ Logging.Log.WriteProfilingMessage(LOGTAG, "CacheUsage", $"Max used cache size: {Duplicati.Library.Utility.Utility.FormatSizeString(cache_size_max_consumed)}");
}
}
catch (Exception ex)
diff --git a/Duplicati/Library/Main/OperationPhase.cs b/Duplicati/Library/Main/OperationPhase.cs
index db09d2366..00e9577e5 100644
--- a/Duplicati/Library/Main/OperationPhase.cs
+++ b/Duplicati/Library/Main/OperationPhase.cs
@@ -1,22 +1,22 @@
// 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
+//
+// 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
+//
+// 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
+//
+// 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.
namespace Duplicati.Library.Main
@@ -37,6 +37,7 @@ namespace Duplicati.Library.Main
Backup_VerificationUpload,
Backup_PostBackupVerify,
Backup_Complete,
+ Backup_RemoteSynchronization,
Restore_Begin,
Restore_RecreateDatabase,
diff --git a/Duplicati/Library/Main/Options.cs b/Duplicati/Library/Main/Options.cs
index c7cd25533..f2489535e 100644
--- a/Duplicati/Library/Main/Options.cs
+++ b/Duplicati/Library/Main/Options.cs
@@ -164,10 +164,15 @@ namespace Duplicati.Library.Main
private static readonly int DEFAULT_RESTORE_FILE_PROCESSORS = Math.Max(1, Environment.ProcessorCount / 2);
///
- /// The default value for the maximum size of the restore volume cache is 100 times the volume size. E.g. for 50MB volumes, the default cache size is 5GB.
+ /// The default value for the restore volume cache hint. When empty, the cache is unlimited (disk-space-aware eviction via ).
///
private const string DEFAULT_RESTORE_VOLUME_CACHE_HINT = "";
+ ///
+ /// The default minimum free disk space to maintain in the temp directory during restore (1 GB).
+ ///
+ private const string DEFAULT_RESTORE_VOLUME_CACHE_MIN_FREE = "1gb";
+
///
/// The default value for the number of volume decryptors during restore
///
@@ -569,12 +574,15 @@ namespace Duplicati.Library.Main
new CommandLineArgument("secret-provider-cache", CommandLineArgument.ArgumentType.Enumeration, Strings.Options.SecretProviderCacheShort, Strings.Options.SecretProviderCacheLong, Enum.GetName(SecretProviderHelper.CachingLevel.None), null, Enum.GetNames(typeof(SecretProviderHelper.CachingLevel))),
new CommandLineArgument("cpu-intensity", CommandLineArgument.ArgumentType.Integer, Strings.Options.CPUIntensityShort, Strings.Options.CPUIntensityLong, "10", null, ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]),
+ new CommandLineArgument("remote-sync-json-config", CommandLineArgument.ArgumentType.String, Strings.Options.RemoteSyncJsonConfigShort, Strings.Options.RemoteSyncJsonConfigLong),
+
new CommandLineArgument("restore-cache-max", CommandLineArgument.ArgumentType.Size, Strings.Options.RestoreCacheMaxShort, Strings.Options.RestoreCacheMaxLong, DEFAULT_RESTORE_CACHE_MAX),
new CommandLineArgument("restore-cache-evict", CommandLineArgument.ArgumentType.Integer, Strings.Options.RestoreCacheEvictShort, Strings.Options.RestoreCacheEvictLong, DEFAULT_RESTORE_CACHE_EVICT.ToString()),
new CommandLineArgument("restore-file-processors", CommandLineArgument.ArgumentType.Integer, Strings.Options.RestoreFileprocessorsShort, Strings.Options.RestoreFileprocessorsLong, DEFAULT_RESTORE_FILE_PROCESSORS.ToString()),
new CommandLineArgument("restore-legacy", CommandLineArgument.ArgumentType.Boolean, Strings.Options.RestoreLegacyShort, Strings.Options.RestoreLegacyLong, "false"),
new CommandLineArgument("restore-preallocate-size", CommandLineArgument.ArgumentType.Boolean, Strings.Options.RestorePreallocateSizeShort, Strings.Options.RestorePreallocateSizeLong, "false"),
new CommandLineArgument("restore-volume-cache-hint", CommandLineArgument.ArgumentType.Size, Strings.Options.RestoreVolumeCacheHintShort, Strings.Options.RestoreVolumeCacheHintLong, DEFAULT_RESTORE_VOLUME_CACHE_HINT),
+ new CommandLineArgument("restore-volume-cache-min-free", CommandLineArgument.ArgumentType.Size, Strings.Options.RestoreVolumeCacheMinFreeShort, Strings.Options.RestoreVolumeCacheMinFreeLong, DEFAULT_RESTORE_VOLUME_CACHE_MIN_FREE),
new CommandLineArgument("restore-volume-decompressors", CommandLineArgument.ArgumentType.Integer, Strings.Options.RestoreVolumeDecompressorsShort, Strings.Options.RestoreVolumeDecompressorsLong, DEFAULT_RESTORE_VOLUME_DECOMPRESSORS.ToString()),
new CommandLineArgument("restore-volume-decryptors", CommandLineArgument.ArgumentType.Integer, Strings.Options.RestoreVolumeDecryptorsShort, Strings.Options.RestoreVolumeDecryptorsLong, DEFAULT_RESTORE_VOLUME_DECRYPTORS.ToString()),
new CommandLineArgument("restore-volume-downloaders", CommandLineArgument.ArgumentType.Integer, Strings.Options.RestoreVolumeDownloadersShort, Strings.Options.RestoreVolumeDownloadersLong, DEFAULT_RESTORE_VOLUME_DOWNLOADERS.ToString()),
@@ -1735,6 +1743,8 @@ namespace Duplicati.Library.Main
private long GetSize(string name, string unit, string @default)
=> Library.Utility.Utility.ParseSizeOption(m_options, name, unit, @default);
+ public string? RemoteSyncJsonConfig => GetString("remote-sync-json-config", null);
+
///
/// Gets the maximum number of data blocks to keep in the cache. If set to 0, the cache is effictively disabled, but some is still kept for bookkeeping.
///
@@ -1805,9 +1815,10 @@ namespace Duplicati.Library.Main
///
public string? MacOSPhotoLibraryPath => GetString("photos-library-path", null);
- //
- // Gets the size of the volume cache used during restore, in MB.
- //
+ ///
+ /// Gets the maximum size of the restore volume cache in bytes.
+ /// Returns -1 when unset (unlimited/disk-space-aware mode), 0 to disable caching, or a positive byte count for a hard cap.
+ ///
public long RestoreVolumeCacheHint
{
get
@@ -1815,12 +1826,19 @@ namespace Duplicati.Library.Main
m_options.TryGetValue("restore-volume-cache-hint", out var s);
if (string.IsNullOrEmpty(s))
- return VolumeSize * 100; // Default to 100 times the volume size
+ return -1L; // Unlimited: disk-space-aware eviction bounded by RestoreVolumeCacheMinFree
return GetSize("restore-volume-cache-hint", "mb", $"{VolumeSize * 100}b");
}
}
+ ///
+ /// Gets the minimum free disk space (in bytes) to maintain in the temp directory during restore.
+ /// Only used when returns -1 (unlimited mode).
+ ///
+ public long RestoreVolumeCacheMinFree
+ => GetSize("restore-volume-cache-min-free", "gb", DEFAULT_RESTORE_VOLUME_CACHE_MIN_FREE);
+
///
/// Gets the number of volume decryptors to use in the restore process
///
diff --git a/Duplicati/Library/Main/ProgressClasses.cs b/Duplicati/Library/Main/ProgressClasses.cs
index 133d28518..5ae7415aa 100644
--- a/Duplicati/Library/Main/ProgressClasses.cs
+++ b/Duplicati/Library/Main/ProgressClasses.cs
@@ -1,22 +1,22 @@
// 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
+//
+// 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
+//
+// 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
+//
+// 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;
@@ -360,6 +360,14 @@ namespace Duplicati.Library.Main
/// Fileoffset.
void UpdateFile(out string filename, out long filesize, out long fileoffset, out bool filecomplete);
+ ///
+ /// Gets the current remote sync destination index (1-based) and total count.
+ /// Returns (0, 0) when not in a remote sync phase.
+ ///
+ /// The 1-based index of the current destination being synced.
+ /// The total number of destinations to sync.
+ void UpdateRemoteSyncDestination(out int destinationIndex, out int destinationCount);
+
///
/// Occurs when the phase has changed
///
@@ -367,7 +375,7 @@ namespace Duplicati.Library.Main
}
///
- /// Interface for updating the backend progress
+ /// Interface for updating the operation progress
///
internal interface IOperationProgressUpdater
{
@@ -377,6 +385,13 @@ namespace Duplicati.Library.Main
void UpdateFileProgress(long offset);
void UpdatefileCount(long filecount, long filesize, bool done);
void UpdatefilesProcessed(long count, long size);
+
+ ///
+ /// Sets the current remote sync destination index and total count.
+ ///
+ /// The 1-based index of the current destination being synced.
+ /// The total number of destinations to sync.
+ void UpdateRemoteSyncDestination(int destinationIndex, int destinationCount);
}
internal interface IOperationProgressUpdaterAndReporter : IOperationProgressUpdater, IOperationProgress
@@ -401,6 +416,9 @@ namespace Duplicati.Library.Main
private bool m_countingFiles;
+ private int m_remoteSyncDestinationIndex;
+ private int m_remoteSyncDestinationCount;
+
public event PhaseChangedDelegate PhaseChanged;
public void UpdatePhase(OperationPhase phase)
@@ -463,6 +481,25 @@ namespace Duplicati.Library.Main
}
}
+ public void UpdateRemoteSyncDestination(int destinationIndex, int destinationCount)
+ {
+ lock (m_lock)
+ {
+ m_remoteSyncDestinationIndex = destinationIndex;
+ m_remoteSyncDestinationCount = destinationCount;
+ }
+ }
+
+ ///
+ void IOperationProgress.UpdateRemoteSyncDestination(out int destinationIndex, out int destinationCount)
+ {
+ lock (m_lock)
+ {
+ destinationIndex = m_remoteSyncDestinationIndex;
+ destinationCount = m_remoteSyncDestinationCount;
+ }
+ }
+
///
/// Update the phase, progress, filesprocessed, filesizeprocessed, filecount, filesize and countingfiles.
///
diff --git a/Duplicati/Library/Main/ResultClasses.cs b/Duplicati/Library/Main/ResultClasses.cs
index d99e40d49..8216e885b 100644
--- a/Duplicati/Library/Main/ResultClasses.cs
+++ b/Duplicati/Library/Main/ResultClasses.cs
@@ -476,6 +476,7 @@ namespace Duplicati.Library.Main
public IRepairResults RepairResults { get; internal set; }
public ITestResults TestResults { get; internal set; }
public ISetLockResults LockResults { get; internal set; }
+ public IRemoteSynchronizationResults[] RemoteSynchronizationResults { get; internal set; } = [];
public override ParsedResultType ParsedResult
{
@@ -539,6 +540,25 @@ namespace Duplicati.Library.Main
public long DeletedSymlinks { get; internal set; }
public string RestorePath { get; internal set; }
+ ///
+ /// Number of LRU evictions triggered by low disk space in the temp directory
+ /// (disk-pressure path, unlimited cache mode only).
+ ///
+ public long CachePressureEvictions { get; internal set; }
+ public bool ShouldSerializeCachePressureEvictions() => CachePressureEvictions > 0;
+
+ ///
+ /// Number of volumes re-downloaded after being evicted due to disk pressure.
+ ///
+ public long CachePressureRedownloads { get; internal set; }
+ public bool ShouldSerializeCachePressureRedownloads() => CachePressureRedownloads > 0;
+
+ ///
+ /// Total number of distinct dblock volumes accessed during the restore.
+ ///
+ public long TotalVolumesAccessed { get; internal set; }
+ public bool ShouldSerializeTotalVolumesAccessed() => TotalVolumesAccessed > 0;
+
public override OperationMode MainOperation { get { return OperationMode.Restore; } }
public IRecreateDatabaseResults RecreateDatabaseResults { get; internal set; }
@@ -828,6 +848,52 @@ namespace Duplicati.Library.Main
public CompactResults(BasicResults p) : base(p) { }
}
+ ///
+ /// Results from a remote synchronization operation to a single destination.
+ ///
+ internal class RemoteSynchronizationResults : BasicResults, IRemoteSynchronizationResults
+ {
+ ///
+ /// The destination URL or identifier.
+ ///
+ public string Destination { get; internal set; } = "";
+
+ ///
+ /// Number of files deleted from the destination.
+ ///
+ public long DeletedFileCount { get; internal set; }
+
+ ///
+ /// Number of files renamed at the destination (retention mode).
+ ///
+ public long RenamedFileCount { get; internal set; }
+
+ ///
+ /// Number of files copied to the destination.
+ ///
+ public long CopiedFileCount { get; internal set; }
+
+ ///
+ /// Number of files verified at the destination.
+ ///
+ public long VerifiedFileCount { get; internal set; }
+
+ ///
+ /// Number of files that failed verification.
+ ///
+ public long FailedVerificationCount { get; internal set; }
+
+ ///
+ /// Total size of files copied in bytes.
+ ///
+ public long CopiedFileSize { get; internal set; }
+
+ public override OperationMode MainOperation { get { return OperationMode.RemoteSynchronization; } }
+
+ public RemoteSynchronizationResults() : base() { }
+ public RemoteSynchronizationResults(BasicResults p) : base(p) { }
+ }
+
internal class ListChangesResults : BasicResults, IListChangesResults
{
public override OperationMode MainOperation { get { return OperationMode.ListChanges; } }
diff --git a/Duplicati/Library/Main/Strings.cs b/Duplicati/Library/Main/Strings.cs
index 751c328c2..34b9b8e9a 100644
--- a/Duplicati/Library/Main/Strings.cs
+++ b/Duplicati/Library/Main/Strings.cs
@@ -363,6 +363,9 @@ namespace Duplicati.Library.Main.Strings
public static string CPUIntensityShort { get { return LC.L("CPU intensity level"); } }
public static string CPUIntensityLong { get { return LC.L("Set the CPU intensity level to limit CPU resource utilization. A higher number translates into a higher utilization budget. E.g. 10 would mean no restrictions. Must be an integer between 1-10."); } }
+ public static string RemoteSyncJsonConfigShort { get { return LC.L("Remote synchronization configuration"); } }
+ public static string RemoteSyncJsonConfigLong { get { return LC.L("JSON string or file path containing remote synchronization configuration"); } }
+
public static string RestoreCacheMaxShort { get { return LC.L("Maximum cache size for restoring files"); } }
public static string RestoreCacheMaxLong { get { return LC.L($"Use this option to set the maximum size of the cache used for restoring files. The cache is used to store the data blocks that are downloaded from the remote storage. It assumes that the value is divisable by the block size, except for when it is 0, which disables the block cache."); } }
public static string RestoreCacheEvictShort { get { return LC.L("Eviction ratio of the data block cache during restore"); } }
@@ -374,7 +377,9 @@ namespace Duplicati.Library.Main.Strings
public static string RestorePreallocateSizeShort { get { return LC.L("Preallocate size of restored files"); } }
public static string RestorePreallocateSizeLong { get { return LC.L("Use this option to toggle whether to set the size of the restored files before they are written to disk. This can help to reduce fragmentation and improve performance on some filesystems."); } }
public static string RestoreVolumeCacheHintShort { get { return LC.L("Hints to the desired maximum size of the restore volume cache"); } }
- public static string RestoreVolumeCacheHintLong { get { return LC.L("Use this option to hint the maximum desired size of the restore volume cache. The restore volume cache is used to store the volumes from the remote storage on local disk. When this number is exceeded, the client tries to evict the least recently used volume(s). This means that the number of volumes kept on disk can exceed this value if they are still being downloaded or are in use."); } }
+ public static string RestoreVolumeCacheHintLong { get { return LC.L("Use this option to hint the maximum desired size of the restore volume cache. The restore volume cache is used to store the volumes from the remote storage on local disk. When this number is exceeded, the client tries to evict the least recently used volume(s). This means that the number of volumes kept on disk can exceed this value if they are still being downloaded or are in use. A bare number is interpreted as megabytes. When left unset, the cache is unlimited and disk-space-aware eviction is used instead (see restore-volume-cache-min-free). Set to 0 to disable caching entirely."); } }
+ public static string RestoreVolumeCacheMinFreeShort { get { return LC.L("Minimum free disk space to maintain in the temp directory during restore"); } }
+ public static string RestoreVolumeCacheMinFreeLong { get { return LC.L("When restore-volume-cache-hint is unset (unlimited mode), the restore volume cache evicts least-recently-used volumes whenever the free space in the temp directory drops below this value. The default is 1gb. A bare number is interpreted as gigabytes. Has no effect when restore-volume-cache-hint is set to a specific size or to 0."); } }
public static string RestoreVolumeDecompressorsShort { get { return LC.L("Number of concurrent FileDecompressor processes used during restore"); } }
public static string RestoreVolumeDecompressorsLong { get { return LC.L("Use this option to set the number of concurrent FileDecompressor processes used during restore. A FileDecompressor processes one volume at a time, and increasing the number of FileDecompressors may improve restore performance if the bottleneck is decompression."); } }
public static string RestoreVolumeDecryptorsShort { get { return LC.L("Number of concurrent FileDecryptor processes used during restore"); } }
diff --git a/Duplicati/Library/Modules/Builtin/Duplicati.Library.Modules.Builtin.csproj b/Duplicati/Library/Modules/Builtin/Duplicati.Library.Modules.Builtin.csproj
index 404f357ad..c4b8904e4 100644
--- a/Duplicati/Library/Modules/Builtin/Duplicati.Library.Modules.Builtin.csproj
+++ b/Duplicati/Library/Modules/Builtin/Duplicati.Library.Modules.Builtin.csproj
@@ -27,7 +27,6 @@
-
diff --git a/Duplicati/Library/Modules/Builtin/GenericModules.cs b/Duplicati/Library/Modules/Builtin/GenericModules.cs
index 81b51469b..37a428f3e 100644
--- a/Duplicati/Library/Modules/Builtin/GenericModules.cs
+++ b/Duplicati/Library/Modules/Builtin/GenericModules.cs
@@ -37,7 +37,6 @@ public static class GenericModules
new CommonOptions(),
new HyperVOptions(),
new MSSQLOptions(),
- new RemoteSynchronizationModule(),
new RunScript(),
new SendHttpMessage(),
new SendJabberMessage(),
diff --git a/Duplicati/Library/RemoteSynchronization/Duplicati.Library.RemoteSynchronization.csproj b/Duplicati/Library/RemoteSynchronization/Duplicati.Library.RemoteSynchronization.csproj
deleted file mode 100644
index 3f48c0758..000000000
--- a/Duplicati/Library/RemoteSynchronization/Duplicati.Library.RemoteSynchronization.csproj
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
- Library
- net10.0
- disable
- enable
- RemoteSynchronization
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Duplicati/Library/RemoteSynchronization/RemoteSynchronizationRunner.cs b/Duplicati/Library/RemoteSynchronization/RemoteSynchronizationRunner.cs
deleted file mode 100644
index 654261037..000000000
--- a/Duplicati/Library/RemoteSynchronization/RemoteSynchronizationRunner.cs
+++ /dev/null
@@ -1,821 +0,0 @@
-// 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;
- }
-
- }
-}
diff --git a/Duplicati/Library/RemoteSynchronization/Strings.cs b/Duplicati/Library/RemoteSynchronization/Strings.cs
deleted file mode 100644
index 1b2277047..000000000
--- a/Duplicati/Library/RemoteSynchronization/Strings.cs
+++ /dev/null
@@ -1,159 +0,0 @@
-// 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.Localization.Short;
-
-namespace RemoteSynchronization
-{
- ///
- /// Localized strings for the Remote Synchronization tool.
- ///
- public static class Strings
- {
- ///
- /// Root command description.
- ///
- public static string RootCommandDescription { get { return LC.L(@"Remote Synchronization Tool
-
-This tool synchronizes two remote backends. The tool assumes that the intent is
-to have the destination match the source.
-
-If the destination has files that are not in the source, they will be deleted
-(or renamed if the retention option is set).
-
-If the destination has files that are also present in the source, but the files
-differ in size, or if the source files have a newer (more recent) timestamp,
-the destination files will be overwritten by the source files. Given that some
-backends do not allow for metadata or timestamp modification, and that the tool
-is run after backup, the destination files should always have a timestamp that
-is newer (or the same if run promptly) compared to the source files.
-
-If the force option is set, the destination will be overwritten by the source,
-regardless of the state of the files. It will also skip the initial comparison,
-and delete (or rename) all files in the destination.
-
-If the verify option is set, the files will be downloaded and compared after
-uploading to ensure that the files are correct. Files that already exist in the
-destination will be verified before being overwritten (if they seemingly match).
-"); } }
-
- ///
- /// The source backend string argument description.
- ///
- public static string SourceBackendDescription { get { return LC.L("The source backend string"); } }
-
- ///
- /// The destination backend string argument description.
- ///
- public static string DestinationBackendDescription { get { return LC.L("The destination backend string"); } }
-
- ///
- /// Auto-create folders option description.
- ///
- public static string AutoCreateFoldersDescription { get { return LC.L("Automatically create folders in the destination backend if they do not exist"); } }
-
- ///
- /// Backend retries option description.
- ///
- public static string BackendRetriesDescription { get { return LC.L("Number of times to recreate a backend on backend errors"); } }
-
- ///
- /// Backend retry delay option description.
- ///
- public static string BackendRetryDelayDescription { get { return LC.L("Delay in milliseconds between backend retries"); } }
-
- ///
- /// Backend retry with exponential backoff option description.
- ///
- public static string BackendRetryWithExponentialBackoffDescription { get { return LC.L("Use exponential backoff for backend retries, multiplying the delay by two for each failure."); } }
-
- ///
- /// Confirm option description.
- ///
- public static string ConfirmDescription { get { return LC.L("Automatically confirm the operation"); } }
-
- ///
- /// Dry-run option description.
- ///
- public static string DryRunDescription { get { return LC.L("Do not actually write or delete files. If not set here, the global options will be checked"); } }
-
- ///
- /// Destination options option description.
- ///
- public static string DstOptionsDescription { get { return LC.L("Options for the destination backend. Each option is a key-value pair separated by an equals sign, e.g. --dst-options key1=value1 key2=value2 [default: empty]"); } }
-
- ///
- /// Force option description.
- ///
- public static string ForceDescription { get { return LC.L("Force the synchronization"); } }
-
- ///
- /// Global options option description.
- ///
- public static string GlobalOptionsDescription { get { return LC.L("Global options all backends. May be overridden by backend specific options (src-options, dst-options). Each option is a key-value pair separated by an equals sign, e.g. --global-options key1=value1 key2=value2 [default: empty]"); } }
-
- ///
- /// Log file option description.
- ///
- public static string LogFileDescription { get { return LC.L("The log file to write to. If not set here, global options will be checked [default: \"\"]"); } }
-
- ///
- /// Log level option description.
- ///
- public static string LogLevelDescription { get { return LC.L("The log level to use. If not set here, global options will be checked"); } }
-
- ///
- /// Parse arguments only option description.
- ///
- public static string ParseArgumentsOnlyDescription { get { return LC.L("Only parse the arguments and then exit"); } }
-
- ///
- /// Progress option description.
- ///
- public static string ProgressDescription { get { return LC.L("Print progress to STDOUT"); } }
-
- ///
- /// Retention option description.
- ///
- public static string RetentionDescription { get { return LC.L("Toggles whether to keep old files. Any deletes will be renames instead"); } }
-
- ///
- /// Retry option description.
- ///
- public static string RetryDescription { get { return LC.L("Number of times to retry on errors"); } }
-
- ///
- /// Source options option description.
- ///
- public static string SrcOptionsDescription { get { return LC.L("Options for the source backend. Each option is a key-value pair separated by an equals sign, e.g. --src-options key1=value1 key2=value2 [default: empty]"); } }
-
- ///
- /// Verify contents option description.
- ///
- public static string VerifyContentsDescription { get { return LC.L("Verify the contents of the files to decide whether the pre-existing destination files should be overwritten"); } }
-
- ///
- /// Verify get after put option description.
- ///
- public static string VerifyGetAfterPutDescription { get { return LC.L("Verify the files after uploading them to ensure that they were uploaded correctly"); } }
-
- }
-}
diff --git a/Duplicati/Library/RestAPI/Runner.cs b/Duplicati/Library/RestAPI/Runner.cs
index 1f40c9d02..4cd8c6fb5 100644
--- a/Duplicati/Library/RestAPI/Runner.cs
+++ b/Duplicati/Library/RestAPI/Runner.cs
@@ -1,22 +1,22 @@
// 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
+//
+// 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
+//
+// 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
+//
+// 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.
#nullable enable
@@ -326,6 +326,9 @@ namespace Duplicati.Server
internal long m_totalFileSize;
internal bool m_stillCounting;
+ internal int m_remoteSyncDestinationIndex;
+ internal int m_remoteSyncDestinationCount;
+
public ProgressState(long taskId, string? backupId)
{
m_backupID = backupId;
@@ -360,6 +363,8 @@ namespace Duplicati.Server
public long TotalFileSize { get { return m_totalFileSize; } }
public bool StillCounting { get { return m_stillCounting; } }
public ActiveTransfer[] ActiveTransfers => m_activeTransfers;
+ public int RemoteSyncDestinationIndex { get { return m_remoteSyncDestinationIndex; } }
+ public int RemoteSyncDestinationCount { get { return m_remoteSyncDestinationCount; } }
#endregion
}
@@ -416,6 +421,7 @@ namespace Duplicati.Server
{
m_operationProgress.UpdateFile(out m_state.m_currentFilename, out m_state.m_currentFilesize, out m_state.m_currentFileoffset, out m_state.m_currentFilecomplete);
m_operationProgress.UpdateOverall(out m_state.m_phase, out m_state.m_overallProgress, out m_state.m_processedFileCount, out m_state.m_processedFileSize, out m_state.m_totalFileCount, out m_state.m_totalFileSize, out m_state.m_stillCounting);
+ m_operationProgress.UpdateRemoteSyncDestination(out m_state.m_remoteSyncDestinationIndex, out m_state.m_remoteSyncDestinationCount);
}
return m_state.Clone();
diff --git a/Duplicati/Library/Utility/Utility.cs b/Duplicati/Library/Utility/Utility.cs
index a9e42f22d..fe68e9bd7 100644
--- a/Duplicati/Library/Utility/Utility.cs
+++ b/Duplicati/Library/Utility/Utility.cs
@@ -1683,7 +1683,8 @@ namespace Duplicati.Library.Utility
return url;
// Use a reportable url without credentials
- var sepIndex = Math.Max(0, url.IndexOf('|')) + 1;
+ var pipeIndex = url.IndexOf('|');
+ var sepIndex = pipeIndex >= 0 ? pipeIndex + 1 : 0;
var length = url.Length - sepIndex;
var shown = Math.Min(length, maxShown);
var hidden = length - shown;
diff --git a/Duplicati/Server/Duplicati.Server.Serialization/Interface/IProgressEventData.cs b/Duplicati/Server/Duplicati.Server.Serialization/Interface/IProgressEventData.cs
index 0dc93e84e..743737d85 100644
--- a/Duplicati/Server/Duplicati.Server.Serialization/Interface/IProgressEventData.cs
+++ b/Duplicati/Server/Duplicati.Server.Serialization/Interface/IProgressEventData.cs
@@ -1,71 +1,83 @@
-// 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.
-
-#nullable enable
-
-namespace Duplicati.Server.Serialization.Interface
-{
- ///
- /// Representation of the current active transfer.
- ///
- /// The action being performed by the backend
- /// The path of the file being transferred
- /// The total size of the file being transferred
- /// The current progress of the file transfer
- /// The current speed of the file transfer
- /// Indicates if the backend operation is blocking
- public sealed record ActiveTransfer(
- string BackendAction,
- string BackendPath,
- long BackendFileSize,
- long BackendFileProgress,
- long BackendSpeed,
- bool BackendIsBlocking
- );
-
- public interface IProgressEventData
- {
- string? BackupID { get; }
- long TaskID { get; }
-
- string BackendAction { get; }
- string? BackendPath { get; }
- long BackendFileSize { get; }
- long BackendFileProgress { get; }
- long BackendSpeed { get; }
- bool BackendIsBlocking { get; }
-
- string? CurrentFilename { get; }
- long CurrentFilesize { get; }
- long CurrentFileoffset { get; }
- bool CurrentFilecomplete { get; }
-
- string Phase { get; }
- float OverallProgress { get; }
- long ProcessedFileCount { get; }
- long ProcessedFileSize { get; }
- long TotalFileCount { get; }
- long TotalFileSize { get; }
- bool StillCounting { get; }
-
- ActiveTransfer[]? ActiveTransfers { get; }
- }
-}
+// 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.
+
+#nullable enable
+
+namespace Duplicati.Server.Serialization.Interface
+{
+ ///
+ /// Representation of the current active transfer.
+ ///
+ /// The action being performed by the backend
+ /// The path of the file being transferred
+ /// The total size of the file being transferred
+ /// The current progress of the file transfer
+ /// The current speed of the file transfer
+ /// Indicates if the backend operation is blocking
+ public sealed record ActiveTransfer(
+ string BackendAction,
+ string BackendPath,
+ long BackendFileSize,
+ long BackendFileProgress,
+ long BackendSpeed,
+ bool BackendIsBlocking
+ );
+
+ public interface IProgressEventData
+ {
+ string? BackupID { get; }
+ long TaskID { get; }
+
+ string BackendAction { get; }
+ string? BackendPath { get; }
+ long BackendFileSize { get; }
+ long BackendFileProgress { get; }
+ long BackendSpeed { get; }
+ bool BackendIsBlocking { get; }
+
+ string? CurrentFilename { get; }
+ long CurrentFilesize { get; }
+ long CurrentFileoffset { get; }
+ bool CurrentFilecomplete { get; }
+
+ string Phase { get; }
+ float OverallProgress { get; }
+ long ProcessedFileCount { get; }
+ long ProcessedFileSize { get; }
+ long TotalFileCount { get; }
+ long TotalFileSize { get; }
+ bool StillCounting { get; }
+
+ ActiveTransfer[]? ActiveTransfers { get; }
+
+ ///
+ /// The 1-based index of the current remote sync destination being processed.
+ /// 0 when not in a remote sync phase.
+ ///
+ int RemoteSyncDestinationIndex { get; }
+
+ ///
+ /// The total number of remote sync destinations to process.
+ /// 0 when not in a remote sync phase.
+ ///
+ int RemoteSyncDestinationCount { get; }
+ }
+}
diff --git a/Duplicati/Server/webroot/ngax/scripts/controllers/StateController.js b/Duplicati/Server/webroot/ngax/scripts/controllers/StateController.js
index 88747583b..b4d549172 100644
--- a/Duplicati/Server/webroot/ngax/scripts/controllers/StateController.js
+++ b/Duplicati/Server/webroot/ngax/scripts/controllers/StateController.js
@@ -43,10 +43,10 @@ backupApp.controller('StateController', function($scope, $timeout, ServerStatus,
pg = 0;
else if (pg >= 0.90)
pg = 0.90;
-
+
// If we have a speed append it
var speed_txt = ($scope.state.lastPgEvent.BackendSpeed < 0) ? "" : " at "+AppUtils.formatSizeString($scope.state.lastPgEvent.BackendSpeed)+"/s";
-
+
var restoring_text = $scope.state.lastPgEvent.Phase == 'Restore_DownloadingRemoteFiles' ? 'Restoring: ' : '';
// Finally construct the whole text
@@ -56,19 +56,30 @@ backupApp.controller('StateController', function($scope, $timeout, ServerStatus,
else if ($scope.state.lastPgEvent.Phase == 'Backup_Finalize' || $scope.state.lastPgEvent.Phase == 'Backup_WaitForUpload')
{
pg = 0.90;
- }
+ }
else if ($scope.state.lastPgEvent.Phase == 'Backup_Delete' || $scope.state.lastPgEvent.Phase == 'Backup_Compact')
{
pg = 0.95;
- }
+ }
else if ($scope.state.lastPgEvent.Phase == 'Backup_VerificationUpload' || $scope.state.lastPgEvent.Phase == 'Backup_PostBackupVerify')
{
pg = 0.98;
- }
+ }
else if ($scope.state.lastPgEvent.Phase == 'Backup_Complete' || $scope.state.lastPgEvent.Phase == 'Backup_WaitForUpload')
{
pg = 1;
}
+ else if ($scope.state.lastPgEvent.Phase == 'Backup_RemoteSynchronization')
+ {
+ if ($scope.state.lastPgEvent.TotalFileCount > 0) {
+ var filesleft = $scope.state.lastPgEvent.TotalFileCount - $scope.state.lastPgEvent.ProcessedFileCount;
+ var sizeleft = $scope.state.lastPgEvent.TotalFileSize - $scope.state.lastPgEvent.ProcessedFileSize;
+ pg = $scope.state.lastPgEvent.OverallProgress > 0 ? $scope.state.lastPgEvent.OverallProgress : 0;
+
+ var speed_txt = ($scope.state.lastPgEvent.BackendSpeed < 0) ? "" : " at "+AppUtils.formatSizeString($scope.state.lastPgEvent.BackendSpeed)+"/s";
+ text = gettextCatalog.getString('Synchronizing remote destination: {{files}} files ({{size}}) to go{{speed_txt}}', { files: filesleft, size: AppUtils.formatSizeString(sizeleft), speed_txt: speed_txt});
+ }
+ }
else if ($scope.state.lastPgEvent.OverallProgress > 0) {
pg = $scope.state.lastPgEvent.OverallProgress;
}
@@ -89,7 +100,7 @@ backupApp.controller('StateController', function($scope, $timeout, ServerStatus,
var txt = $scope.state.lastPgEvent == null ? '' : ($scope.state.lastPgEvent.Phase || '');
function handleClick(ix) {
- if (ix == 0)
+ if (ix == 0)
{
AppService.post('/task/' + taskId + '/stop');
$scope.StopReqId = taskId;
diff --git a/Duplicati/Server/webroot/ngax/scripts/services/AppService.js b/Duplicati/Server/webroot/ngax/scripts/services/AppService.js
index 88cc8ff4f..4ff29434d 100644
--- a/Duplicati/Server/webroot/ngax/scripts/services/AppService.js
+++ b/Duplicati/Server/webroot/ngax/scripts/services/AppService.js
@@ -2,6 +2,7 @@ backupApp.service('AppService', function ($http, $cookies, $q, $cookies, DialogS
this.apiurl = '../api/v1';
this.access_token = null;
this.access_token_promise = null;
+ this.redirecting_to_login = false;
const self = this;
@@ -12,6 +13,50 @@ backupApp.service('AppService', function ($http, $cookies, $q, $cookies, DialogS
});
}
+ function isRefreshStatusCode(status) {
+ return status === 400 || status === 401 || status === 415;
+ }
+
+ function isRefreshUrl(url) {
+ if (!url)
+ return false;
+
+ if (url.indexOf('/auth/refresh/logout') >= 0)
+ return false;
+
+ if (url.indexOf('/api/v1/auth/refresh') >= 0 || url.indexOf('/auth/refresh') >= 0)
+ return true;
+
+ return false;
+ }
+
+ this.isRefreshFailureResponse = function(response) {
+ return response != null
+ && isRefreshStatusCode(response.status)
+ && isRefreshUrl(response.config && response.config.url);
+ };
+
+ function clearAuthState() {
+ self.clearAccessToken();
+ localStorage.removeItem('v1:persist:duplicati:refreshNonce');
+ }
+
+ function redirectToLogin() {
+ if (self.redirecting_to_login)
+ return;
+
+ self.redirecting_to_login = true;
+ window.location.href = '/login.html';
+ }
+
+ function handleRefreshFailure(response) {
+ if (response != null)
+ response.refreshAuthFailure = true;
+
+ clearAuthState();
+ redirectToLogin();
+ }
+
var setupConfig = function (method, options, data, targeturl) {
options = options || {};
@@ -66,9 +111,15 @@ backupApp.service('AppService', function ($http, $cookies, $q, $cookies, DialogS
}
);
},
- () => {
- // Fail, but report the original failed response, not the refresh response
- deferred.reject(response);
+ refreshResponse => {
+ // If the refresh endpoint itself failed in a terminal auth state,
+ // do not hide it behind the original response.
+ if (self.isRefreshFailureResponse(refreshResponse)) {
+ deferred.reject(refreshResponse);
+ } else {
+ // Fail, but report the original failed response, not the refresh response
+ deferred.reject(response);
+ }
}
);
@@ -101,9 +152,11 @@ backupApp.service('AppService', function ($http, $cookies, $q, $cookies, DialogS
self.access_token_promise = deferred.promise;
const storedNonce = localStorage.getItem('v1:persist:duplicati:refreshNonce');
- const body = storedNonce ? { Nonce: storedNonce } : undefined;
+ const body = storedNonce ? { Nonce: storedNonce } : {};
+ const refreshUrl = self.apiurl + '/auth/refresh';
+ const refreshConfig = setupConfig('POST', {}, body, refreshUrl);
- $http.post(self.apiurl + '/auth/refresh', body)
+ $http.post(refreshUrl, body, refreshConfig)
.then(function (response) {
self.access_token = response.data.AccessToken;
self.access_token_promise = null;
@@ -115,8 +168,12 @@ backupApp.service('AppService', function ($http, $cookies, $q, $cookies, DialogS
self.access_token = null;
self.access_token_promise = null;
+ // Explicit terminal auth failures for /auth/refresh should not be retried.
+ if (self.isRefreshFailureResponse(response)) {
+ handleRefreshFailure(response);
+ }
// Auth error, refresh token invalid
- if (response.status == 401)
+ else if (response.status == 401)
loginRequired();
deferred.reject(response);
diff --git a/Duplicati/Server/webroot/ngax/scripts/services/ServerStatus.js b/Duplicati/Server/webroot/ngax/scripts/services/ServerStatus.js
index e55fc2a5d..ea2f3ac81 100644
--- a/Duplicati/Server/webroot/ngax/scripts/services/ServerStatus.js
+++ b/Duplicati/Server/webroot/ngax/scripts/services/ServerStatus.js
@@ -45,6 +45,7 @@ backupApp.service('ServerStatus', function ($rootScope, $timeout, AppService, Ap
'Backup_VerificationUpload': gettextCatalog.getString('Uploading verification file …'),
'Backup_PostBackupVerify': gettextCatalog.getString('Verifying backend data …'),
'Backup_Complete': gettextCatalog.getString('Backup complete!'),
+ 'Backup_RemoteSynchronization': gettextCatalog.getString('Synchronizing secondary destination …'),
'Restore_Begin': gettextCatalog.getString('Starting restore …'),
'Restore_RecreateDatabase': gettextCatalog.getString('Rebuilding local database …'),
'Restore_PreRestoreVerify': gettextCatalog.getString('Verifying remote data …'),
@@ -146,7 +147,7 @@ backupApp.service('ServerStatus', function ($rootScope, $timeout, AppService, Ap
return;
}
-
+
checkTaskState(taskid);
}, 1000);
}
@@ -162,7 +163,7 @@ backupApp.service('ServerStatus', function ($rootScope, $timeout, AppService, Ap
// if (lastTaskId != null && currentTaskId != lastTaskId && waitingfortask[lastTaskId] != null)
// checkTaskState(lastTaskId);
-
+
// lastTaskId = currentTaskId;
// });
@@ -315,7 +316,24 @@ backupApp.service('ServerStatus', function ($rootScope, $timeout, AppService, Ap
const webSocketUnauthorizedCode = 4401;
const unauthorizedCode = 401;
+ function isTerminalRefreshFailure(response) {
+ return (response && response.refreshAuthFailure === true)
+ || (AppService.isRefreshFailureResponse && AppService.isRefreshFailureResponse(response));
+ }
+
function handleConnectionError(response) {
+ if (isTerminalRefreshFailure(response)) {
+ if (websocketReconnectTimer != null) {
+ window.clearInterval(websocketReconnectTimer);
+ websocketReconnectTimer = null;
+ }
+
+ state.failedAuthAttempts++;
+ state.connectionState = 'unauthorized';
+ $rootScope.$broadcast('serverstatechanged');
+ return;
+ }
+
state.failedConnectionAttempts++;
if (response.status === webSocketUnauthorizedCode || response.status === unauthorizedCode)
{
@@ -326,7 +344,7 @@ backupApp.service('ServerStatus', function ($rootScope, $timeout, AppService, Ap
// First failure, we ignore
if (state.connectionState == 'connected' && state.failedConnectionAttempts == 1) {
updateServerState();
- } else if (state.failedAuthAttempts > 1 && (response.status === webSocketUnauthorizedCode || response.status === unauthorizedCode)) {
+ } else if (state.failedAuthAttempts > 1 && (response.status === webSocketUnauthorizedCode || response.status === unauthorizedCode)) {
state.connectionState = 'unauthorized';
$rootScope.$broadcast('serverstatechanged');
} else {
@@ -372,7 +390,7 @@ backupApp.service('ServerStatus', function ($rootScope, $timeout, AppService, Ap
}
- this.reconnect = function (fastcall) {
+ this.reconnect = function (fastcall) {
if (websocketReconnectTimer != null) {
window.clearInterval(websocketReconnectTimer);
websocketReconnectTimer = null;
@@ -391,3 +409,4 @@ backupApp.service('ServerStatus', function ($rootScope, $timeout, AppService, Ap
this.reconnect();
});
+
diff --git a/Duplicati/Server/webroot/ngclient/package-lock.json b/Duplicati/Server/webroot/ngclient/package-lock.json
index 324e2ec76..2e022940b 100644
--- a/Duplicati/Server/webroot/ngclient/package-lock.json
+++ b/Duplicati/Server/webroot/ngclient/package-lock.json
@@ -9,13 +9,13 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
- "@duplicati/ngclient": "^0.0.212"
+ "@duplicati/ngclient": "^0.0.213"
}
},
"node_modules/@duplicati/ngclient": {
- "version": "0.0.212",
- "resolved": "https://registry.npmjs.org/@duplicati/ngclient/-/ngclient-0.0.212.tgz",
- "integrity": "sha512-wMkN7DmsMGRgGb4fvr6Xm+OhGo0G4+DM+uqFbXnB/bJ5ra8kQ54sGbnumWny4naVuLrGcbdOI/UOXdHTyebirQ==",
+ "version": "0.0.213",
+ "resolved": "https://registry.npmjs.org/@duplicati/ngclient/-/ngclient-0.0.213.tgz",
+ "integrity": "sha512-qouihkFAvoMcsRtYtICIaZZas0vBssrGysYtaPugB4c2YxTyCoDsW7zarupr2KTbwBHyZYA7IQIxZX/lp0PrKw==",
"license": "MIT"
}
}
diff --git a/Duplicati/Server/webroot/ngclient/package.json b/Duplicati/Server/webroot/ngclient/package.json
index 99b3241c4..150034509 100644
--- a/Duplicati/Server/webroot/ngclient/package.json
+++ b/Duplicati/Server/webroot/ngclient/package.json
@@ -4,6 +4,6 @@
"description": "Modern Angular Client for Duplicati",
"license": "MIT",
"dependencies": {
- "@duplicati/ngclient": "^0.0.212"
+ "@duplicati/ngclient": "^0.0.213"
}
-}
+}
\ No newline at end of file
diff --git a/Duplicati/UnitTest/DiskImage/DiskImageHelperFactory.cs b/Duplicati/UnitTest/DiskImage/DiskImageHelperFactory.cs
deleted file mode 100644
index 1df5df1ff..000000000
--- a/Duplicati/UnitTest/DiskImage/DiskImageHelperFactory.cs
+++ /dev/null
@@ -1,93 +0,0 @@
-// Copyright (C) 2025, The Duplicati Team
-// https://duplicati.com, hello@duplicati.com
-//
-// Permission is hereby granted, free of charge, to any person obtaining a
-// copy of this software and associated documentation files (the "Software"),
-// to deal in the Software without restriction, including without limitation
-// the rights to use, copy, modify, merge, publish, distribute, sublicense,
-// and/or sell copies of the Software, and to permit persons to whom the
-// Software is furnished to do so, subject to the following conditions:
-//
-// The above copyright notice and this permission notice shall be included in
-// all copies or substantial portions of the Software.
-//
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-// DEALINGS IN THE SOFTWARE.
-
-using System;
-using System.Runtime.InteropServices;
-
-#nullable enable
-
-namespace Duplicati.UnitTest.DiskImage
-{
- ///
- /// Factory class for creating the appropriate implementation
- /// based on the host operating system.
- ///
- public static class DiskImageHelperFactory
- {
- ///
- /// Creates and returns the appropriate disk image helper for the current operating system.
- ///
- /// An implementation for the current OS.
- /// Thrown when the current OS is not supported.
- public static IDiskImageHelper Create()
- {
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- {
- return new WindowsDiskImageHelper();
- }
- else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
- {
- return new LinuxDiskImageHelper();
- }
- else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
- {
- return new MacOSDiskImageHelper();
- }
- else
- {
- throw new PlatformNotSupportedException($"Disk image operations are not supported on this operating system: {RuntimeInformation.OSDescription}");
- }
- }
-
- ///
- /// Gets a value indicating whether disk image operations are supported on the current platform.
- ///
- /// true if disk image operations are supported; otherwise, false.
- public static bool IsSupported
- {
- get
- {
- return RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
- || RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
- || RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
- }
- }
-
- ///
- /// Gets the name of the current platform.
- ///
- /// The platform name ("Windows", "Linux", "macOS", or "Unknown").
- public static string CurrentPlatformName
- {
- get
- {
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- return "Windows";
- else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
- return "Linux";
- else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
- return "macOS";
- else
- return "Unknown";
- }
- }
- }
-}
diff --git a/Duplicati/UnitTest/DiskImage/DiskImageTestHelpers.cs b/Duplicati/UnitTest/DiskImage/DiskImageTestHelpers.cs
deleted file mode 100644
index 089b55e48..000000000
--- a/Duplicati/UnitTest/DiskImage/DiskImageTestHelpers.cs
+++ /dev/null
@@ -1,159 +0,0 @@
-// Copyright (C) 2025, The Duplicati Team
-// https://duplicati.com, hello@duplicati.com
-//
-// Permission is hereby granted, free of charge, to any person obtaining a
-// copy of this software and associated documentation files (the "Software"),
-// to deal in the Software without restriction, including without limitation
-// the rights to use, copy, modify, merge, publish, distribute, sublicense,
-// and/or sell copies of the Software, and to permit persons to whom the
-// Software is furnished to do so, subject to the following conditions:
-//
-// The above copyright notice and this permission notice shall be included in
-// all copies or substantial portions of the Software.
-//
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-// DEALINGS IN THE SOFTWARE.
-
-using System;
-using System.IO;
-using System.Threading;
-using System.Threading.Tasks;
-using Duplicati.Proprietary.DiskImage;
-using Duplicati.Proprietary.DiskImage.Disk;
-using Duplicati.Proprietary.DiskImage.General;
-
-#nullable enable
-
-namespace Duplicati.UnitTest.DiskImage
-{
- ///
- /// Helper methods for disk image unit tests.
- /// Provides common operations for creating, initializing, and managing disk images.
- ///
- internal static class DiskImageTestHelpers
- {
- private const long MiB = 1024 * 1024;
-
- ///
- /// Creates a raw disk interface for the specified disk identifier based on the current platform.
- ///
- /// The disk identifier (e.g., \\.\PhysicalDriveN on Windows, /dev/loopN on Linux).
- /// An initialized IRawDisk instance for the specified platform.
- /// Thrown when the current OS is not supported.
- internal static IRawDisk CreateRawDiskForIdentifier(string diskIdentifier)
- {
- if (OperatingSystem.IsWindows())
- return new Duplicati.Proprietary.DiskImage.Disk.Windows(diskIdentifier);
- else if (OperatingSystem.IsLinux())
- return new Duplicati.Proprietary.DiskImage.Disk.Linux(diskIdentifier);
- else if (OperatingSystem.IsMacOS())
- return new Duplicati.Proprietary.DiskImage.Disk.Mac(diskIdentifier);
- else
- throw new PlatformNotSupportedException("Unsupported operating system.");
- }
-
- ///
- /// Creates a disk image file with the specified parameters.
- ///
- /// The disk image helper.
- /// The path where the disk image will be created.
- /// The size of the disk in bytes.
- /// The partition table type (GPT, MBR, etc.).
- /// Array of tuples specifying filesystem type and size for each partition.
- /// The disk identifier for the created disk.
- internal static string CreateDiskWithPartitions(
- IDiskImageHelper diskHelper,
- string imagePath,
- long size,
- PartitionTableType tableType,
- (FileSystemType, long)[] partitions)
- {
- var diskIdentifier = diskHelper.CreateDisk(imagePath, size);
- diskHelper.InitializeDisk(diskIdentifier, tableType, partitions);
- diskHelper.FlushDisk(diskIdentifier);
- diskHelper.Unmount(diskIdentifier);
- return diskIdentifier;
- }
-
- ///
- /// Fills a partition with well-known test data (repeating pattern of 0x00-0xFF).
- ///
- /// The raw disk interface.
- /// The starting offset of the partition in bytes.
- /// The size of the partition in bytes.
- /// Cancellation token.
- internal static async Task FillPartitionWithTestDataAsync(
- IRawDisk rawDisk,
- long partitionOffset,
- long partitionSize,
- CancellationToken cancellationToken = default)
- {
- const int bufferSize = 64 * 1024; // 64KB chunks
- var buffer = new byte[bufferSize];
-
- long bytesWritten = 0;
- long currentOffset = partitionOffset;
-
- while (bytesWritten < partitionSize)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- // Fill buffer with well-known pattern (repeating 0x00-0xFF)
- for (int i = 0; i < buffer.Length; i++)
- {
- buffer[i] = (byte)((bytesWritten + i) & 0xFF);
- }
-
- var remaining = partitionSize - bytesWritten;
- var toWrite = (int)Math.Min(bufferSize, remaining);
-
- await rawDisk.WriteBytesAsync(currentOffset, buffer.AsMemory(0, toWrite), cancellationToken);
-
- bytesWritten += toWrite;
- currentOffset += toWrite;
- }
- }
-
- ///
- /// Gets the file extension for disk images based on the current platform.
- ///
- /// The platform-specific disk image extension (vhdx, img, or dmg).
- internal static string GetPlatformDiskImageExtension()
- {
- if (OperatingSystem.IsWindows())
- return "vhdx";
- else if (OperatingSystem.IsLinux())
- return "img";
- else if (OperatingSystem.IsMacOS())
- return "dmg";
- else
- throw new PlatformNotSupportedException("Unsupported operating system.");
- }
-
- ///
- /// Deletes a file if it exists, catching any exceptions.
- ///
- /// The path to the file to delete.
- internal static void SafeDeleteFile(string? filePath)
- {
- if (string.IsNullOrEmpty(filePath))
- return;
-
- try
- {
- if (File.Exists(filePath))
- File.Delete(filePath);
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Warning: Failed to delete file {filePath}: {ex.Message}");
- }
- }
- }
-
-}
\ No newline at end of file
diff --git a/Duplicati/UnitTest/DiskImage/DiskImageTests.cs b/Duplicati/UnitTest/DiskImage/DiskImageTests.cs
index 4cebeaaae..b06f78962 100644
--- a/Duplicati/UnitTest/DiskImage/DiskImageTests.cs
+++ b/Duplicati/UnitTest/DiskImage/DiskImageTests.cs
@@ -29,1233 +29,1597 @@ using Duplicati.Library.Interface;
using Duplicati.Library.Main;
using Duplicati.Proprietary.DiskImage;
using Duplicati.Proprietary.DiskImage.General;
+using Duplicati.UnitTest.DiskImage.Helpers;
using NUnit.Framework;
using Assert = NUnit.Framework.Legacy.ClassicAssert;
#nullable enable
-namespace Duplicati.UnitTest.DiskImage
+namespace Duplicati.UnitTest.DiskImage;
+
+///
+/// Unit tests for the DiskImage backup and restore functionality.
+/// These tests use disk image files managed to test the full backup and restore flow.
+///
+[TestFixture]
+[Category("DiskImage")]
+[Platform("Win,MacOsX,Linux")]
+public class DiskImageTests : BasicSetupHelper
{
+ private string _sourceImagePath = null!;
+ private string _restoreImagePath = null!;
+ private string _sourceMountPath = null!;
+ private string _restoreMountPath = null!;
+ private IDiskImageHelper _diskHelper = null!;
+
+ private const long MiB = 1024 * 1024;
+
///
- /// Unit tests for the DiskImage backup and restore functionality.
- /// These tests use disk image files managed to test the full backup and restore flow.
+ /// Sets up the test environment before each test.
+ /// Creates the disk helper and temporary disk image paths.
///
- [TestFixture]
- [Category("DiskImage")]
- [Platform("Win,MacOsX,Linux")]
- public class DiskImageTests : BasicSetupHelper
+ [SetUp]
+ public void DiskImageSetUp()
{
- private string _sourceImagePath = null!;
- private string _restoreImagePath = null!;
- private string _sourceMountPath = null!;
- private string _restoreMountPath = null!;
- private DiskImage.IDiskImageHelper _diskHelper = null!;
+ // Create the appropriate disk image helper for the current platform
+ _diskHelper = DiskImageHelperFactory.Create();
- private const long MiB = 1024 * 1024;
-
- ///
- /// Sets up the test environment before each test.
- /// Creates the disk helper and temporary disk image paths.
- ///
- [SetUp]
- public void DiskImageSetUp()
+ // Check for admin privileges
+ if (!_diskHelper.HasRequiredPrivileges())
{
- // Create the appropriate disk image helper for the current platform
- _diskHelper = DiskImage.DiskImageHelperFactory.Create();
-
- // Check for admin privileges
- if (!_diskHelper.HasRequiredPrivileges())
- {
- Assert.Ignore("DiskImage tests require administrator privileges");
- }
-
- // Create temp disk image paths
- var extension = DiskImageTestHelpers.GetPlatformDiskImageExtension();
- _sourceImagePath = Path.Combine(DATAFOLDER, $"duplicati_test_source_{Guid.NewGuid()}.{extension}");
- _restoreImagePath = Path.Combine(DATAFOLDER, $"duplicati_test_restore_{Guid.NewGuid()}.{extension}");
- _sourceMountPath = Path.Combine(DATAFOLDER, $"mnt_source_{Guid.NewGuid()}");
- _restoreMountPath = Path.Combine(DATAFOLDER, $"mnt_restore_{Guid.NewGuid()}");
- Directory.CreateDirectory(_sourceMountPath);
- Directory.CreateDirectory(_restoreMountPath);
+ Assert.Ignore("DiskImage tests require administrator privileges");
}
- ///
- /// Cleans up the test environment after each test.
- /// Detaches and deletes disk image files.
- ///
- [TearDown]
- public void DiskImageTearDown()
+ // Create temp disk image paths
+ var extension = DiskImageTestHelpers.GetPlatformDiskImageExtension();
+ _sourceImagePath = Path.Combine(DATAFOLDER, $"duplicati_test_source_{Guid.NewGuid()}.{extension}");
+ _restoreImagePath = Path.Combine(DATAFOLDER, $"duplicati_test_restore_{Guid.NewGuid()}.{extension}");
+ _sourceMountPath = Path.Combine(DATAFOLDER, $"mnt_source_{Guid.NewGuid()}");
+ _restoreMountPath = Path.Combine(DATAFOLDER, $"mnt_restore_{Guid.NewGuid()}");
+ Directory.CreateDirectory(_sourceMountPath);
+ Directory.CreateDirectory(_restoreMountPath);
+ }
+
+ ///
+ /// Cleans up the test environment after each test.
+ /// Detaches and deletes disk image files.
+ ///
+ [TearDown]
+ public void DiskImageTearDown()
+ {
+ // Cleanup disk images
+ try
+ {
+ _diskHelper.CleanupDisk(_sourceImagePath);
+ }
+ catch (Exception ex)
+ {
+ TestContext.Progress.WriteLine($"Warning: Failed to cleanup source disk image: {ex.Message}");
+ }
+
+ try
+ {
+ _diskHelper.CleanupDisk(_restoreImagePath);
+ }
+ catch (Exception ex)
+ {
+ TestContext.Progress.WriteLine($"Warning: Failed to cleanup restore disk image: {ex.Message}");
+ }
+
+ try
+ {
+ _diskHelper.Dispose();
+ }
+ catch (Exception ex)
+ {
+ TestContext.Progress.WriteLine($"Warning: Failed to dispose disk helper: {ex.Message}");
+ }
+
+ DiskImageTestHelpers.SafeDeleteFile(_sourceImagePath);
+ DiskImageTestHelpers.SafeDeleteFile(_restoreImagePath);
+ }
+
+ #region GPT Single Partition
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_FAT32() =>
+ FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.FAT32, 0)]);
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_NTFS()
+ {
+ if (!OperatingSystem.IsWindows())
+ Assert.Ignore("Test_GPT_NTFS is only supported on Windows.");
+ return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.NTFS, 0)]);
+ }
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_APFS()
+ {
+ if (!OperatingSystem.IsMacOS())
+ Assert.Ignore("APFS is only supported on macOS.");
+ return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.APFS, 0)]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_HFSPlus()
+ {
+ if (!OperatingSystem.IsMacOS())
+ Assert.Ignore("HFSPlus is only supported on macOS.");
+ return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.HFSPlus, 0)]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_ExFAT() =>
+ FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.ExFAT, 0)]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_Ext2()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("Ext2 is only supported on Linux.");
+ return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext2, 0)]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_Ext3()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("Ext3 is only supported on Linux.");
+ return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext3, 0)]);
+ }
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_Ext4()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("Ext4 is only supported on Linux.");
+ return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext4, 0)]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_XFS()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("XFS is only supported on Linux.");
+ return FullRoundTrip((int)(310 * MiB), PartitionTableType.GPT, [(FileSystemType.XFS, 0)]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_Btrfs()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("Btrfs is only supported on Linux.");
+ return FullRoundTrip((int)(110 * MiB), PartitionTableType.GPT, [(FileSystemType.Btrfs, 0)]);
+ }
+
+ #endregion
+
+ #region MBR Single Partition
+
+ // APFS is only supported on GPT partition tables.
+
+ [Test, Category("DiskImage")]
+ public Task Test_MBR_FAT32() =>
+ FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.FAT32, 0)]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_HFSPlus()
+ {
+ if (!OperatingSystem.IsMacOS())
+ Assert.Ignore("HFSPlus is only supported on macOS.");
+ return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.HFSPlus, 0)]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_ExFAT() =>
+ FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.ExFAT, 0)]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_NTFS()
+ {
+ if (!OperatingSystem.IsWindows())
+ Assert.Ignore("Test_MBR_NTFS is only supported on Windows.");
+ return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.NTFS, 0)]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_Ext2()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("Ext2 is only supported on Linux.");
+ return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext2, 0)]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_Ext3()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("Ext3 is only supported on Linux.");
+ return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext3, 0)]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_Ext4()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("Ext4 is only supported on Linux.");
+ return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext4, 0)]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_XFS()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("XFS is only supported on Linux.");
+ return FullRoundTrip((int)(310 * MiB), PartitionTableType.MBR, [(FileSystemType.XFS, 0)]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_Btrfs()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("Btrfs is only supported on Linux.");
+ return FullRoundTrip((int)(110 * MiB), PartitionTableType.MBR, [(FileSystemType.Btrfs, 0)]);
+ }
+
+ #endregion
+
+ #region Unknown Partition Table
+
+ [Test, Category("DiskImage")]
+ public Task Test_Unknown_NoPartitions() =>
+ FullRoundTrip((int)(50 * MiB), PartitionTableType.Unknown, []);
+
+ #endregion
+
+ #region GPT Two Partitions
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_FAT32_FAT32() =>
+ FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_APFS_APFS()
+ {
+ if (!OperatingSystem.IsMacOS())
+ Assert.Ignore("APFS is only supported on macOS.");
+ return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.APFS, 50 * MiB),
+ (FileSystemType.APFS, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_HFSPlus_HFSPlus()
+ {
+ if (!OperatingSystem.IsMacOS())
+ Assert.Ignore("HFSPlus is only supported on macOS.");
+ return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.HFSPlus, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_APFS_HFSPlus()
+ {
+ if (!OperatingSystem.IsMacOS())
+ Assert.Ignore("APFS and HFSPlus are only supported on macOS.");
+ return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.APFS, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_FAT32_APFS()
+ {
+ if (!OperatingSystem.IsMacOS())
+ Assert.Ignore("APFS is only supported on macOS.");
+ return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.APFS, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_ExFAT_HFSPlus()
+ {
+ if (!OperatingSystem.IsMacOS())
+ Assert.Ignore("HFSPlus is only supported on macOS.");
+ return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.ExFAT, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_FAT32_ExFAT() =>
+ FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.ExFAT, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_NTFS_FAT32()
+ {
+ if (!OperatingSystem.IsWindows())
+ Assert.Ignore("This test is only supported on Windows.");
+ return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.NTFS, 50 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_NTFS_NTFS()
+ {
+ if (!OperatingSystem.IsWindows())
+ Assert.Ignore("This test is only supported on Windows.");
+ return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.NTFS, 50 * MiB),
+ (FileSystemType.NTFS, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_Ext4_Ext4()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("Ext4 is only supported on Linux.");
+ return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.Ext4, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_Ext4_FAT32()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("This test is only supported on Linux.");
+ return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_Ext4_XFS()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("This test is only supported on Linux.");
+ return FullRoundTrip((int)(360 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.XFS, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_XFS_FAT32()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("This test is only supported on Linux.");
+ return FullRoundTrip((int)(360 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.XFS, 310 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_Btrfs_FAT32()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("This test is only supported on Linux.");
+ return FullRoundTrip((int)(160 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.Btrfs, 110 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+ }
+
+ #endregion
+
+ #region MBR Two Partitions
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_FAT32_FAT32() =>
+ FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+
+ // APFS is only supported on GPT partition tables.
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_HFSPlus_HFSPlus()
+ {
+ if (!OperatingSystem.IsMacOS())
+ Assert.Ignore("HFSPlus is only supported on macOS.");
+ return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.HFSPlus, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+ }
+
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_FAT32_ExFAT()
+ {
+ return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.ExFAT, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_FAT32_HFSPlus()
+ {
+ if (!OperatingSystem.IsMacOS())
+ Assert.Ignore("HFSPlus is only supported on macOS.");
+ return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_NTFS_FAT32()
+ {
+ if (!OperatingSystem.IsWindows())
+ Assert.Ignore("This test is only supported on Windows.");
+ return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.NTFS, 50 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_Ext4_Ext4()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("Ext4 is only supported on Linux.");
+ return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.Ext4, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_FAT32_Ext4()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("This test is only supported on Linux.");
+ return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.Ext4, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_XFS_Ext4()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("This test is only supported on Linux.");
+ return FullRoundTrip((int)(360 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.XFS, 310 * MiB),
+ (FileSystemType.Ext4, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_Btrfs_Ext4()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("This test is only supported on Linux.");
+ return FullRoundTrip((int)(160 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.Btrfs, 110 * MiB),
+ (FileSystemType.Ext4, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_XFS_Btrfs()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("This test is only supported on Linux.");
+ return FullRoundTrip((int)(420 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.XFS, 310 * MiB),
+ (FileSystemType.Btrfs, 0)
+ ]);
+ }
+
+ #endregion
+
+ #region GPT Three Partitions
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_FAT32_FAT32_FAT32()
+ {
+ return FullRoundTrip((int)(150 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_APFS_APFS_APFS()
+ {
+ if (!OperatingSystem.IsMacOS())
+ Assert.Ignore("APFS is only supported on macOS.");
+ return FullRoundTrip((int)(150 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.APFS, 50 * MiB),
+ (FileSystemType.APFS, 50 * MiB),
+ (FileSystemType.APFS, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_NTFS_NTFS_NTFS()
+ {
+ if (!OperatingSystem.IsWindows())
+ Assert.Ignore("NTFS is only supported on Windows.");
+ return FullRoundTrip((int)(150 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.NTFS, 50 * MiB),
+ (FileSystemType.NTFS, 50 * MiB),
+ (FileSystemType.NTFS, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_HFSPlus_HFSPlus_HFSPlus()
+ {
+ if (!OperatingSystem.IsMacOS())
+ Assert.Ignore("HFSPlus is only supported on macOS.");
+ return FullRoundTrip((int)(150 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.HFSPlus, 50 * MiB),
+ (FileSystemType.HFSPlus, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_APFS_HFSPlus_APFS()
+ {
+ if (!OperatingSystem.IsMacOS())
+ Assert.Ignore("APFS and HFSPlus are only supported on macOS.");
+ return FullRoundTrip((int)(150 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.APFS, 50 * MiB),
+ (FileSystemType.HFSPlus, 50 * MiB),
+ (FileSystemType.APFS, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_FAT32_APFS_HFSPlus()
+ {
+ if (!OperatingSystem.IsMacOS())
+ Assert.Ignore("APFS and HFSPlus are only supported on macOS.");
+ return FullRoundTrip((int)(150 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.APFS, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_ExFAT_APFS_HFSPlus()
+ {
+ if (!OperatingSystem.IsMacOS())
+ Assert.Ignore("APFS and HFSPlus are only supported on macOS.");
+ return FullRoundTrip((int)(150 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.ExFAT, 50 * MiB),
+ (FileSystemType.APFS, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_Ext4_Ext4_Ext4()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("Ext4 is only supported on Linux.");
+ return FullRoundTrip((int)(150 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.Ext4, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_FAT32_Ext4_XFS()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("This test is only supported on Linux.");
+ return FullRoundTrip((int)(410 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.XFS, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_Ext4_XFS_Btrfs()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("This test is only supported on Linux.");
+ return FullRoundTrip((int)(470 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.XFS, 310 * MiB),
+ (FileSystemType.Btrfs, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_XFS_Ext4_Ext4()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("This test is only supported on Linux.");
+ return FullRoundTrip((int)(410 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.XFS, 310 * MiB),
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.Ext4, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_Btrfs_Ext4_FAT32()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("This test is only supported on Linux.");
+ return FullRoundTrip((int)(210 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.Btrfs, 110 * MiB),
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+ }
+
+ #endregion
+
+ #region MBR Three Partitions
+
+ // APFS is only supported on GPT partition tables.
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_FAT32_FAT32_FAT32()
+ {
+ return FullRoundTrip((int)(150 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_HFSPlus_HFSPlus_HFSPlus()
+ {
+ if (!OperatingSystem.IsMacOS())
+ Assert.Ignore("HFSPlus is only supported on macOS.");
+ return FullRoundTrip((int)(150 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.HFSPlus, 50 * MiB),
+ (FileSystemType.HFSPlus, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_FAT32_ExFAT_HFSPlus()
+ {
+ if (!OperatingSystem.IsMacOS())
+ Assert.Ignore("HFSPlus is only supported on macOS.");
+ return FullRoundTrip((int)(150 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.ExFAT, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+ }
+
+ // GPT NTFS for Windows - local-only (tested via single partition)
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_NTFS_FAT32_ExFAT()
+ {
+ if (!OperatingSystem.IsWindows())
+ Assert.Ignore("This test is only supported on Windows.");
+ return FullRoundTrip((int)(150 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.NTFS, 50 * MiB),
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.ExFAT, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_Ext4_Ext4_Ext4()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("Ext4 is only supported on Linux.");
+ return FullRoundTrip((int)(150 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.Ext4, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_FAT32_Ext4_XFS()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("This test is only supported on Linux.");
+ return FullRoundTrip((int)(410 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.XFS, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_Ext4_XFS_Btrfs()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("This test is only supported on Linux.");
+ return FullRoundTrip((int)(470 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.XFS, 310 * MiB),
+ (FileSystemType.Btrfs, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_XFS_Btrfs_Ext4()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("This test is only supported on Linux.");
+ return FullRoundTrip((int)(470 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.XFS, 310 * MiB),
+ (FileSystemType.Btrfs, 110 * MiB),
+ (FileSystemType.Ext4, 0)
+ ]);
+ }
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_Btrfs_Ext4_XFS()
+ {
+ if (!OperatingSystem.IsLinux())
+ Assert.Ignore("This test is only supported on Linux.");
+ return FullRoundTrip((int)(470 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.Btrfs, 110 * MiB),
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.XFS, 0)
+ ]);
+ }
+
+ #endregion
+
+ public async Task FullRoundTrip(int size, PartitionTableType tableType, (FileSystemType, long)[] partitions)
+ {
+ await TestContext.Progress.WriteLineAsync("Test: Full Round-Trip Backup + Restore");
+
+ var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, size);
+ await TestContext.Progress.WriteLineAsync($"Source Disk created at: {_sourceImagePath}");
+
+ var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, tableType, partitions);
+ await TestContext.Progress.WriteLineAsync($"Source Disk initialized with partition(s): {string.Join(", ", sourcePartitions)}");
+
+ // Populate source partition with test data
+ foreach (var partition in sourcePartitions)
+ await ToolTests.GenerateTestData(partition, 10, 5, 2, 1024);
+ _diskHelper.FlushDisk(sourceDrivePath);
+ _diskHelper.Unmount(sourceDrivePath);
+ await TestContext.Progress.WriteLineAsync($"Test data generated on source partition(s)");
+
+ // Backup
+ var backupResults = RunBackup(sourceDrivePath);
+ TestUtils.AssertResults(backupResults);
+ await TestContext.Progress.WriteLineAsync($"Backup completed successfully");
+
+ // Setup restore target disk image with same geometry
+ var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
+ _diskHelper.InitializeDisk(restoreDrivePath, PartitionTableType.GPT, []);
+ _diskHelper.Unmount(restoreDrivePath);
+ await TestContext.Progress.WriteLineAsync($"Restore disk image created at: {_restoreImagePath}");
+
+ // Restore
+ var restoreResults = RunRestore(restoreDrivePath);
+ TestUtils.AssertResults(restoreResults);
+ await TestContext.Progress.WriteLineAsync($"Restore completed successfully");
+
+ // Reattach drives in readonly
+ sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
+ restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
+ await TestContext.Progress.WriteLineAsync($"Source and restore disks re-attached as read-only for verification");
+
+ // Verify partition table matches. Mount before verification, to make disks online on Windows.
+ string[] restorePartitions = [];
+ if (tableType != PartitionTableType.Unknown)
+ {
+ var fsTypes = partitions.Select(p => p.Item1).ToArray();
+ sourcePartitions = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
+ restorePartitions = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
+ }
+ VerifyPartitionTableMatches(sourceDrivePath, restoreDrivePath);
+ await TestContext.Progress.WriteLineAsync($"Partition table verified to match source");
+
+ // Verify data matches byte-for-byte
+ foreach (var (sourcePartition, restorePartition) in sourcePartitions.Zip(restorePartitions, (s, r) => (s, r)))
+ CompareDirectories(sourcePartition, restorePartition);
+
+ await TestContext.Progress.WriteLineAsync($"Restored data verified to match source");
+ }
+
+ [Test]
+ [Category("DiskImage")]
+ public async Task Test_GeometryMetadata_Verification()
+ {
+ await TestContext.Progress.WriteLineAsync("Test: Full Round-Trip Backup + Restore");
+
+ var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, 100 * MiB);
+ await TestContext.Progress.WriteLineAsync($"Source Disk created at: {_sourceImagePath}");
+
+ (FileSystemType, long)[] partitions = [(FileSystemType.FAT32, 50 * MiB), (FileSystemType.FAT32, 0)];
+ var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, PartitionTableType.GPT, partitions);
+ _diskHelper.Unmount(sourceDrivePath);
+ await TestContext.Progress.WriteLineAsync($"Source Disk initialized with partition(s): {string.Join(", ", sourcePartitions)}");
+
+ // Backup
+ var backupResults = RunBackup(sourceDrivePath);
+ TestUtils.AssertResults(backupResults);
+
+ // List backup contents and verify geometry.json is present
+ using (var c = new Controller("file://" + TARGETFOLDER, TestOptions, null))
+ {
+ var listResults = c.List("*");
+ TestUtils.AssertResults(listResults);
+
+ // Check that geometry.json is in the backup
+ var files = listResults.Files;
+ Assert.That(files, Is.Not.Null);
+ var geometryFile = files.FirstOrDefault(f => f.Path.EndsWith("geometry.json"));
+ Assert.That(geometryFile, Is.Not.Null, "geometry.json should be present in the backup");
+ }
+
+ // Verify geometry metadata contains correct information
+ VerifyGeometryMetadata(TARGETFOLDER, TestOptions, partitions);
+ }
+
+ [Test]
+ [Category("DiskImage")]
+ public async Task Test_SourceProvider_Enumeration()
+ {
+ TestContext.Progress.WriteLine("Test: SourceProvider Enumeration");
+
+ // Setup: Create GPT disk with one FAT32 partition
+ var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, (100 * MiB));
+ var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, PartitionTableType.GPT, [(FileSystemType.FAT32, 0)]);
+ TestContext.Progress.WriteLine($"Source disk image created at: {_sourceImagePath}");
+
+ // Directly instantiate SourceProvider
+ // Note: When directly instantiating SourceProvider, we don't use the @ prefix
+ // The @ prefix is only for Controller to recognize it as a remote source
+ var sourceUrl = $"diskimage://{sourceDrivePath}";
+ using var provider = new SourceProvider(sourceUrl, "", new Dictionary());
+
+ // Initialize the provider
+ await provider.Initialize(CancellationToken.None);
+
+ // Enumerate entries
+ var diskEntries = new List();
+ await foreach (var entry in provider.Enumerate(CancellationToken.None))
+ {
+ diskEntries.Add(entry);
+ TestContext.Progress.WriteLine($"Found entry: {entry.Path} (IsFolder: {entry.IsFolder}, Size: {entry.Size})");
+ }
+
+ // Verify hierarchy: disk → partition → filesystem → files
+ Assert.That(diskEntries.Count, Is.GreaterThan(0), "Should have at least one entry (the disk)");
+
+ // Fetch the rest of the enumaration.
+ // Check for disk entry
+ var diskEntry = diskEntries.FirstOrDefault(e => e.IsRootEntry);
+ Assert.That(diskEntry, Is.Not.Null, "Should have a root disk entry");
+
+ var entries = new List();
+ await foreach (var entry in diskEntry!.Enumerate(CancellationToken.None))
+ entries.Add(entry);
+
+ TestContext.Progress.WriteLine($"Total entries found: {entries.Count}");
+
+ // Check for geometry.json
+ var geometryEntry = entries.FirstOrDefault(e => e.Path.EndsWith("geometry.json"));
+ Assert.That(geometryEntry, Is.Not.Null, "Should have geometry.json entry");
+
+ // Check for partition entries
+ var partitionEntries = entries.Where(e => e.Path.Contains("part_")).ToList();
+ Assert.That(partitionEntries.Count, Is.GreaterThan(0), "Should have at least one partition entry");
+
+ // Add all of the filesystems.
+ var fsEntries = new List();
+ foreach (var partitionEntry in partitionEntries)
+ await foreach (var entry in partitionEntry.Enumerate(CancellationToken.None))
+ fsEntries.Add(entry);
+
+ // Check for filesystem entries
+ Assert.That(fsEntries.All(x => x.Path.Contains("fs_")));
+ Assert.That(fsEntries.Count, Is.GreaterThan(0), "Should have at least one filesystem entry");
+ }
+
+ [Test]
+ [Category("DiskImage")]
+ public async Task Test_AutoUnmountOption_RestoreWhileOnline()
+ {
+ TestContext.Progress.WriteLine("Test: Auto Unmount Option - Restore While Disk Online");
+
+ // Setup source disk image: 100MB, GPT, single FAT32 partition
+ var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, (100 * MiB));
+ var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, PartitionTableType.GPT, [(FileSystemType.FAT32, 0)]);
+ TestContext.Progress.WriteLine($"Source disk image created at: {_sourceImagePath}");
+
+ // Generate some test data
+ await ToolTests.GenerateTestData(sourcePartitions.First(), 10, 5, 2, 1024);
+ _diskHelper.FlushDisk(sourceDrivePath);
+ _diskHelper.Unmount(sourceDrivePath);
+ TestContext.Progress.WriteLine($"Test data generated on source partition");
+
+ // Backup
+ var backupResults = RunBackup(sourceDrivePath);
+ TestUtils.AssertResults(backupResults);
+ TestContext.Progress.WriteLine($"Backup completed successfully");
+
+ // Create and attach disk image
+ var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, (100 * MiB));
+
+ // Initialize disk (the restore will overwrite this, but we need it formatted)
+ _diskHelper.InitializeDisk(restoreDrivePath, PartitionTableType.GPT, [(FileSystemType.FAT32, 50 * MiB), (FileSystemType.FAT32, 0)]);
+ _diskHelper.Mount(restoreDrivePath, _restoreMountPath);
+ TestContext.Progress.WriteLine($"Restore disk image created and kept online at: {_restoreImagePath}");
+
+ // First attempt: Restore without auto-unmount option should fail
+ // because the disk is online and in use
+ TestContext.Progress.WriteLine("Attempting restore without auto-unmount (should fail)...");
+ var options = new Dictionary(TestOptions)
+ {
+ ["restore-path"] = $"@diskimage://{restoreDrivePath}",
+ ["overwrite"] = "true",
+ ["restore-file-processors"] = "1",
+ // Explicitly disable auto-unmount (though it's disabled by default)
+ ["diskimage-restore-auto-unmount"] = "false"
+ };
+
+ using (var c = new Controller("file://" + TARGETFOLDER, options, null))
{
- // Cleanup disk images
try
- {
- _diskHelper.CleanupDisk(_sourceImagePath);
- }
- catch (Exception ex)
- {
- TestContext.Progress.WriteLine($"Warning: Failed to cleanup source disk image: {ex.Message}");
- }
-
- try
- {
- _diskHelper.CleanupDisk(_restoreImagePath);
- }
- catch (Exception ex)
- {
- TestContext.Progress.WriteLine($"Warning: Failed to cleanup restore disk image: {ex.Message}");
- }
-
- try
- {
- _diskHelper.Dispose();
- }
- catch (Exception ex)
- {
- TestContext.Progress.WriteLine($"Warning: Failed to dispose disk helper: {ex.Message}");
- }
-
- DiskImageTestHelpers.SafeDeleteFile(_sourceImagePath);
- DiskImageTestHelpers.SafeDeleteFile(_restoreImagePath);
- }
-
- #region GPT Single Partition
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_FAT32() =>
- FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.FAT32, 0)]);
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_NTFS()
- {
- if (!OperatingSystem.IsWindows())
- Assert.Ignore("Test_GPT_NTFS is only supported on Windows.");
- return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.NTFS, 0)]);
- }
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_APFS()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("APFS is only supported on macOS.");
- return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.APFS, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_HFSPlus()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("HFSPlus is only supported on macOS.");
- return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.HFSPlus, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_ExFAT() =>
- FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.ExFAT, 0)]);
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_Ext2()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext2 is only supported on Linux.");
- return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext2, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_Ext3()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext3 is only supported on Linux.");
- return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext3, 0)]);
- }
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_Ext4()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext4 is only supported on Linux.");
- return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext4, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_XFS()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("XFS is only supported on Linux.");
- return FullRoundTrip((int)(310 * MiB), PartitionTableType.GPT, [(FileSystemType.XFS, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_Btrfs()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Btrfs is only supported on Linux.");
- return FullRoundTrip((int)(110 * MiB), PartitionTableType.GPT, [(FileSystemType.Btrfs, 0)]);
- }
-
- #endregion
-
- #region MBR Single Partition
-
- // APFS is only supported on GPT partition tables.
-
- [Test, Category("DiskImage")]
- public Task Test_MBR_FAT32() =>
- FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.FAT32, 0)]);
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_HFSPlus()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("HFSPlus is only supported on macOS.");
- return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.HFSPlus, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_ExFAT() =>
- FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.ExFAT, 0)]);
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_NTFS()
- {
- if (!OperatingSystem.IsWindows())
- Assert.Ignore("Test_MBR_NTFS is only supported on Windows.");
- return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.NTFS, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_Ext2()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext2 is only supported on Linux.");
- return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext2, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_Ext3()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext3 is only supported on Linux.");
- return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext3, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_Ext4()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext4 is only supported on Linux.");
- return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext4, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_XFS()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("XFS is only supported on Linux.");
- return FullRoundTrip((int)(310 * MiB), PartitionTableType.MBR, [(FileSystemType.XFS, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_Btrfs()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Btrfs is only supported on Linux.");
- return FullRoundTrip((int)(110 * MiB), PartitionTableType.MBR, [(FileSystemType.Btrfs, 0)]);
- }
-
- #endregion
-
- #region Unknown Partition Table
-
- [Test, Category("DiskImage")]
- public Task Test_Unknown_NoPartitions() =>
- FullRoundTrip((int)(50 * MiB), PartitionTableType.Unknown, []);
-
- #endregion
-
- #region GPT Two Partitions
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_FAT32_FAT32() =>
- FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_APFS_APFS()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("APFS is only supported on macOS.");
- return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.APFS, 50 * MiB),
- (FileSystemType.APFS, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_HFSPlus_HFSPlus()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("HFSPlus is only supported on macOS.");
- return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.HFSPlus, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_APFS_HFSPlus()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("APFS and HFSPlus are only supported on macOS.");
- return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.APFS, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_FAT32_APFS()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("APFS is only supported on macOS.");
- return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.APFS, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_ExFAT_HFSPlus()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("HFSPlus is only supported on macOS.");
- return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.ExFAT, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_FAT32_ExFAT() =>
- FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.ExFAT, 0)
- ]);
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_NTFS_FAT32()
- {
- if (!OperatingSystem.IsWindows())
- Assert.Ignore("This test is only supported on Windows.");
- return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.NTFS, 50 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
- }
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_NTFS_NTFS()
- {
- if (!OperatingSystem.IsWindows())
- Assert.Ignore("This test is only supported on Windows.");
- return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.NTFS, 50 * MiB),
- (FileSystemType.NTFS, 0)
- ]);
- }
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_Ext4_Ext4()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext4 is only supported on Linux.");
- return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.Ext4, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_Ext4_FAT32()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_Ext4_XFS()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTrip((int)(360 * MiB), PartitionTableType.GPT, [
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.XFS, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_XFS_FAT32()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTrip((int)(360 * MiB), PartitionTableType.GPT, [
- (FileSystemType.XFS, 310 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_Btrfs_FAT32()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTrip((int)(160 * MiB), PartitionTableType.GPT, [
- (FileSystemType.Btrfs, 110 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
- }
-
- #endregion
-
- #region MBR Two Partitions
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_FAT32_FAT32() =>
- FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
-
- // APFS is only supported on GPT partition tables.
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_HFSPlus_HFSPlus()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("HFSPlus is only supported on macOS.");
- return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [
- (FileSystemType.HFSPlus, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_FAT32_ExFAT()
- {
- return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.ExFAT, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_FAT32_HFSPlus()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("HFSPlus is only supported on macOS.");
- return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_NTFS_FAT32()
- {
- if (!OperatingSystem.IsWindows())
- Assert.Ignore("This test is only supported on Windows.");
- return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [
- (FileSystemType.NTFS, 50 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_Ext4_Ext4()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext4 is only supported on Linux.");
- return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.Ext4, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_FAT32_Ext4()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.Ext4, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_XFS_Ext4()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTrip((int)(360 * MiB), PartitionTableType.MBR, [
- (FileSystemType.XFS, 310 * MiB),
- (FileSystemType.Ext4, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_Btrfs_Ext4()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTrip((int)(160 * MiB), PartitionTableType.MBR, [
- (FileSystemType.Btrfs, 110 * MiB),
- (FileSystemType.Ext4, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_XFS_Btrfs()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTrip((int)(420 * MiB), PartitionTableType.MBR, [
- (FileSystemType.XFS, 310 * MiB),
- (FileSystemType.Btrfs, 0)
- ]);
- }
-
- #endregion
-
- #region GPT Three Partitions
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_FAT32_FAT32_FAT32()
- {
- return FullRoundTrip((int)(150 * MiB), PartitionTableType.GPT, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
- }
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_APFS_APFS_APFS()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("APFS is only supported on macOS.");
- return FullRoundTrip((int)(150 * MiB), PartitionTableType.GPT, [
- (FileSystemType.APFS, 50 * MiB),
- (FileSystemType.APFS, 50 * MiB),
- (FileSystemType.APFS, 0)
- ]);
- }
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_NTFS_NTFS_NTFS()
- {
- if (!OperatingSystem.IsWindows())
- Assert.Ignore("NTFS is only supported on Windows.");
- return FullRoundTrip((int)(150 * MiB), PartitionTableType.GPT, [
- (FileSystemType.NTFS, 50 * MiB),
- (FileSystemType.NTFS, 50 * MiB),
- (FileSystemType.NTFS, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_HFSPlus_HFSPlus_HFSPlus()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("HFSPlus is only supported on macOS.");
- return FullRoundTrip((int)(150 * MiB), PartitionTableType.GPT, [
- (FileSystemType.HFSPlus, 50 * MiB),
- (FileSystemType.HFSPlus, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_APFS_HFSPlus_APFS()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("APFS and HFSPlus are only supported on macOS.");
- return FullRoundTrip((int)(150 * MiB), PartitionTableType.GPT, [
- (FileSystemType.APFS, 50 * MiB),
- (FileSystemType.HFSPlus, 50 * MiB),
- (FileSystemType.APFS, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_FAT32_APFS_HFSPlus()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("APFS and HFSPlus are only supported on macOS.");
- return FullRoundTrip((int)(150 * MiB), PartitionTableType.GPT, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.APFS, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_ExFAT_APFS_HFSPlus()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("APFS and HFSPlus are only supported on macOS.");
- return FullRoundTrip((int)(150 * MiB), PartitionTableType.GPT, [
- (FileSystemType.ExFAT, 50 * MiB),
- (FileSystemType.APFS, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_Ext4_Ext4_Ext4()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext4 is only supported on Linux.");
- return FullRoundTrip((int)(150 * MiB), PartitionTableType.GPT, [
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.Ext4, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_FAT32_Ext4_XFS()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTrip((int)(410 * MiB), PartitionTableType.GPT, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.XFS, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_Ext4_XFS_Btrfs()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTrip((int)(470 * MiB), PartitionTableType.GPT, [
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.XFS, 310 * MiB),
- (FileSystemType.Btrfs, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_XFS_Ext4_Ext4()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTrip((int)(410 * MiB), PartitionTableType.GPT, [
- (FileSystemType.XFS, 310 * MiB),
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.Ext4, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_Btrfs_Ext4_FAT32()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTrip((int)(210 * MiB), PartitionTableType.GPT, [
- (FileSystemType.Btrfs, 110 * MiB),
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
- }
-
- #endregion
-
- #region MBR Three Partitions
-
- // APFS is only supported on GPT partition tables.
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_FAT32_FAT32_FAT32()
- {
- return FullRoundTrip((int)(150 * MiB), PartitionTableType.MBR, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_HFSPlus_HFSPlus_HFSPlus()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("HFSPlus is only supported on macOS.");
- return FullRoundTrip((int)(150 * MiB), PartitionTableType.MBR, [
- (FileSystemType.HFSPlus, 50 * MiB),
- (FileSystemType.HFSPlus, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_FAT32_ExFAT_HFSPlus()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("HFSPlus is only supported on macOS.");
- return FullRoundTrip((int)(150 * MiB), PartitionTableType.MBR, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.ExFAT, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
- // GPT NTFS for Windows - local-only (tested via single partition)
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_NTFS_FAT32_ExFAT()
- {
- if (!OperatingSystem.IsWindows())
- Assert.Ignore("This test is only supported on Windows.");
- return FullRoundTrip((int)(150 * MiB), PartitionTableType.GPT, [
- (FileSystemType.NTFS, 50 * MiB),
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.ExFAT, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_Ext4_Ext4_Ext4()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext4 is only supported on Linux.");
- return FullRoundTrip((int)(150 * MiB), PartitionTableType.MBR, [
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.Ext4, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_FAT32_Ext4_XFS()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTrip((int)(410 * MiB), PartitionTableType.MBR, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.XFS, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_Ext4_XFS_Btrfs()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTrip((int)(470 * MiB), PartitionTableType.MBR, [
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.XFS, 310 * MiB),
- (FileSystemType.Btrfs, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_XFS_Btrfs_Ext4()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTrip((int)(470 * MiB), PartitionTableType.MBR, [
- (FileSystemType.XFS, 310 * MiB),
- (FileSystemType.Btrfs, 110 * MiB),
- (FileSystemType.Ext4, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_Btrfs_Ext4_XFS()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTrip((int)(470 * MiB), PartitionTableType.MBR, [
- (FileSystemType.Btrfs, 110 * MiB),
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.XFS, 0)
- ]);
- }
-
- #endregion
-
- public async Task FullRoundTrip(int size, PartitionTableType tableType, (FileSystemType, long)[] partitions)
- {
- await TestContext.Progress.WriteLineAsync("Test: Full Round-Trip Backup + Restore");
-
- var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, size);
- await TestContext.Progress.WriteLineAsync($"Source Disk created at: {_sourceImagePath}");
-
- var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, tableType, partitions);
- await TestContext.Progress.WriteLineAsync($"Source Disk initialized with partition(s): {string.Join(", ", sourcePartitions)}");
-
- // Populate source partition with test data
- foreach (var partition in sourcePartitions)
- await ToolTests.GenerateTestData(partition, 10, 5, 2, 1024);
- _diskHelper.FlushDisk(sourceDrivePath);
- _diskHelper.Unmount(sourceDrivePath);
- await TestContext.Progress.WriteLineAsync($"Test data generated on source partition(s)");
-
- // Backup
- var backupResults = RunBackup(sourceDrivePath);
- TestUtils.AssertResults(backupResults);
- await TestContext.Progress.WriteLineAsync($"Backup completed successfully");
-
- // Setup restore target disk image with same geometry
- var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
- _diskHelper.InitializeDisk(restoreDrivePath, PartitionTableType.GPT, []);
- _diskHelper.Unmount(restoreDrivePath);
- await TestContext.Progress.WriteLineAsync($"Restore disk image created at: {_restoreImagePath}");
-
- // Restore
- var restoreResults = RunRestore(restoreDrivePath);
- TestUtils.AssertResults(restoreResults);
- await TestContext.Progress.WriteLineAsync($"Restore completed successfully");
-
- // Reattach drives in readonly
- sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
- restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
- await TestContext.Progress.WriteLineAsync($"Source and restore disks re-attached as read-only for verification");
-
- // Verify partition table matches. Mount before verification, to make disks online on Windows.
- string[] restorePartitions = [];
- if (tableType != PartitionTableType.Unknown)
- {
- var fsTypes = partitions.Select(p => p.Item1).ToArray();
- sourcePartitions = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
- restorePartitions = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
- }
- VerifyPartitionTableMatches(sourceDrivePath, restoreDrivePath);
- await TestContext.Progress.WriteLineAsync($"Partition table verified to match source");
-
- // Verify data matches byte-for-byte
- foreach (var (sourcePartition, restorePartition) in sourcePartitions.Zip(restorePartitions, (s, r) => (s, r)))
- CompareDirectories(sourcePartition, restorePartition);
-
- await TestContext.Progress.WriteLineAsync($"Restored data verified to match source");
- }
-
- [Test]
- [Category("DiskImage")]
- public async Task Test_GeometryMetadata_Verification()
- {
- await TestContext.Progress.WriteLineAsync("Test: Full Round-Trip Backup + Restore");
-
- var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, 100 * MiB);
- await TestContext.Progress.WriteLineAsync($"Source Disk created at: {_sourceImagePath}");
-
- (FileSystemType, long)[] partitions = [(FileSystemType.FAT32, 50 * MiB), (FileSystemType.FAT32, 0)];
- var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, PartitionTableType.GPT, partitions);
- _diskHelper.Unmount(sourceDrivePath);
- await TestContext.Progress.WriteLineAsync($"Source Disk initialized with partition(s): {string.Join(", ", sourcePartitions)}");
-
- // Backup
- var backupResults = RunBackup(sourceDrivePath);
- TestUtils.AssertResults(backupResults);
-
- // List backup contents and verify geometry.json is present
- using (var c = new Controller("file://" + TARGETFOLDER, TestOptions, null))
- {
- var listResults = c.List("*");
- TestUtils.AssertResults(listResults);
-
- // Check that geometry.json is in the backup
- var files = listResults.Files;
- Assert.That(files, Is.Not.Null);
- var geometryFile = files.FirstOrDefault(f => f.Path.EndsWith("geometry.json"));
- Assert.That(geometryFile, Is.Not.Null, "geometry.json should be present in the backup");
- }
-
- // Verify geometry metadata contains correct information
- VerifyGeometryMetadata(TARGETFOLDER, TestOptions, partitions);
- }
-
- [Test]
- [Category("DiskImage")]
- public async Task Test_SourceProvider_Enumeration()
- {
- TestContext.Progress.WriteLine("Test: SourceProvider Enumeration");
-
- // Setup: Create GPT disk with one FAT32 partition
- var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, (100 * MiB));
- var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, PartitionTableType.GPT, [(FileSystemType.FAT32, 0)]);
- TestContext.Progress.WriteLine($"Source disk image created at: {_sourceImagePath}");
-
- // Directly instantiate SourceProvider
- // Note: When directly instantiating SourceProvider, we don't use the @ prefix
- // The @ prefix is only for Controller to recognize it as a remote source
- var sourceUrl = $"diskimage://{sourceDrivePath}";
- using var provider = new SourceProvider(sourceUrl, "", new Dictionary());
-
- // Initialize the provider
- await provider.Initialize(CancellationToken.None);
-
- // Enumerate entries
- var diskEntries = new List();
- await foreach (var entry in provider.Enumerate(CancellationToken.None))
- {
- diskEntries.Add(entry);
- TestContext.Progress.WriteLine($"Found entry: {entry.Path} (IsFolder: {entry.IsFolder}, Size: {entry.Size})");
- }
-
- // Verify hierarchy: disk → partition → filesystem → files
- Assert.That(diskEntries.Count, Is.GreaterThan(0), "Should have at least one entry (the disk)");
-
- // Fetch the rest of the enumaration.
- // Check for disk entry
- var diskEntry = diskEntries.FirstOrDefault(e => e.IsRootEntry);
- Assert.That(diskEntry, Is.Not.Null, "Should have a root disk entry");
-
- var entries = new List();
- await foreach (var entry in diskEntry!.Enumerate(CancellationToken.None))
- entries.Add(entry);
-
- TestContext.Progress.WriteLine($"Total entries found: {entries.Count}");
-
- // Check for geometry.json
- var geometryEntry = entries.FirstOrDefault(e => e.Path.EndsWith("geometry.json"));
- Assert.That(geometryEntry, Is.Not.Null, "Should have geometry.json entry");
-
- // Check for partition entries
- var partitionEntries = entries.Where(e => e.Path.Contains("part_")).ToList();
- Assert.That(partitionEntries.Count, Is.GreaterThan(0), "Should have at least one partition entry");
-
- // Add all of the filesystems.
- var fsEntries = new List();
- foreach (var partitionEntry in partitionEntries)
- await foreach (var entry in partitionEntry.Enumerate(CancellationToken.None))
- fsEntries.Add(entry);
-
- // Check for filesystem entries
- Assert.That(fsEntries.All(x => x.Path.Contains("fs_")));
- Assert.That(fsEntries.Count, Is.GreaterThan(0), "Should have at least one filesystem entry");
- }
-
- [Test]
- [Category("DiskImage")]
- public async Task Test_AutoUnmountOption_RestoreWhileOnline()
- {
- TestContext.Progress.WriteLine("Test: Auto Unmount Option - Restore While Disk Online");
-
- // Setup source disk image: 100MB, GPT, single FAT32 partition
- var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, (100 * MiB));
- var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, PartitionTableType.GPT, [(FileSystemType.FAT32, 0)]);
- TestContext.Progress.WriteLine($"Source disk image created at: {_sourceImagePath}");
-
- // Generate some test data
- await ToolTests.GenerateTestData(sourcePartitions.First(), 10, 5, 2, 1024);
- _diskHelper.FlushDisk(sourceDrivePath);
- _diskHelper.Unmount(sourceDrivePath);
- TestContext.Progress.WriteLine($"Test data generated on source partition");
-
- // Backup
- var backupResults = RunBackup(sourceDrivePath);
- TestUtils.AssertResults(backupResults);
- TestContext.Progress.WriteLine($"Backup completed successfully");
-
- // Create and attach disk image
- var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, (100 * MiB));
-
- // Initialize disk (the restore will overwrite this, but we need it formatted)
- _diskHelper.InitializeDisk(restoreDrivePath, PartitionTableType.GPT, [(FileSystemType.FAT32, 50 * MiB), (FileSystemType.FAT32, 0)]);
- _diskHelper.Mount(restoreDrivePath, _restoreMountPath);
- TestContext.Progress.WriteLine($"Restore disk image created and kept online at: {_restoreImagePath}");
-
- // First attempt: Restore without auto-unmount option should fail
- // because the disk is online and in use
- TestContext.Progress.WriteLine("Attempting restore without auto-unmount (should fail)...");
- var options = new Dictionary(TestOptions)
- {
- ["restore-path"] = $"@diskimage://{restoreDrivePath}",
- ["overwrite"] = "true",
- ["restore-file-processors"] = "1",
- // Explicitly disable auto-unmount (though it's disabled by default)
- ["diskimage-restore-auto-unmount"] = "false"
- };
-
- using (var c = new Controller("file://" + TARGETFOLDER, options, null))
- {
- try
- {
- var results = c.Restore(["*"]);
-
- // Verify that the restore failed (has errors)
- Assert.That(results.Errors.Any(), Is.True,
- "Restore should fail when target disk is online and auto-unmount is not enabled");
- TestContext.Progress.WriteLine($"Restore failed as expected with errors: {string.Join(", ", results.Errors)}");
- }
- catch (IOException)
- {
- // Assumed to fail
- }
- catch (UserInformationException)
- {
- // Assumed to fail
- }
- }
-
- // Second attempt: Restore with auto-unmount option should succeed
- TestContext.Progress.WriteLine("Attempting restore with auto-unmount enabled (should succeed)...");
-
- options["diskimage-restore-auto-unmount"] = "true";
- using (var c = new Controller("file://" + TARGETFOLDER, options, null))
{
var results = c.Restore(["*"]);
- Assert.That(!results.Errors.Any() && results.Warnings.Count() <= 1);
+ // Verify that the restore failed (has errors)
+ Assert.That(results.Errors.Any(), Is.True,
+ "Restore should fail when target disk is online and auto-unmount is not enabled");
+ TestContext.Progress.WriteLine($"Restore failed as expected with errors: {string.Join(", ", results.Errors)}");
}
- TestContext.Progress.WriteLine($"Restore completed successfully with auto-unmount option");
-
- // Mount before verification, to make disks online on Windows.
- sourcePartitions = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true);
- var restorePartitions = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true);
-
- // Verify partition table matches
- VerifyPartitionTableMatches(sourceDrivePath, restoreDrivePath);
-
- // Verify data matches byte-for-byte
- var sourcePartition = sourcePartitions.First();
- var restorePartition = restorePartitions.First();
- CompareDirectories(sourcePartition, restorePartition);
- }
-
- #region Helper Methods
-
- ///
- /// Runs a backup operation using the Controller.
- ///
- /// The physical drive path to backup.
- /// The backup results.
- private IBackupResults RunBackup(string physicalDrivePath)
- {
- var options = new Dictionary(TestOptions);
- options["enable-module"] = "diskimage";
- options["concurrency-fileprocessors"] = "1";
-
- using var c = new Controller("file://" + TARGETFOLDER, options, null);
- var sourceUrl = $"@/testdisk|diskimage://{physicalDrivePath}";
- return c.Backup(new[] { sourceUrl });
- }
-
- ///
- /// Runs a restore operation using the Controller.
- ///
- /// The physical drive path to restore to.
- /// The restore results.
- private IRestoreResults RunRestore(string restoreDrivePath)
- {
- var options = new Dictionary(TestOptions);
- options["restore-path"] = $"@diskimage://{restoreDrivePath}";
- options["overwrite"] = "true";
- options["restore-file-processors"] = "1";
-
- using var c = new Controller("file://" + TARGETFOLDER, options, null);
- var results = c.Restore(new[] { "*" });
-
- return results;
- }
-
- ///
- /// Recursively compares two directories for structural and content equality.
- ///
- /// The source directory path.
- /// The restored directory path.
- private void CompareDirectories(string sourcePath, string restorePath)
- {
- var options = new EnumerationOptions
+ catch (IOException)
{
- RecurseSubdirectories = true,
- IgnoreInaccessible = true,
- AttributesToSkip = FileAttributes.System | FileAttributes.ReparsePoint
- };
-
- // Get all files in source, except for system files, which cannot be read directly
- var sourceFiles = Directory.EnumerateFiles(sourcePath, "*", options)
- .Select(f => Path.GetRelativePath(sourcePath, f))
- .Where(f => !string.IsNullOrEmpty(f))
- .OrderBy(f => f)
- .ToList();
- var restoreFiles = Directory.EnumerateFiles(restorePath, "*", options)
- .Select(f => Path.GetRelativePath(restorePath, f))
- .Where(f => !string.IsNullOrEmpty(f))
- .OrderBy(f => f)
- .ToList();
-
- Assert.That(sourceFiles.Count, Is.EqualTo(restoreFiles.Count),
- $"Number of files in source and restore should match. Source: {sourceFiles.Count}, Restore: {restoreFiles.Count}");
- Assert.That(sourceFiles.Count, Is.GreaterThan(0),
- "Source partition should contain files to verify");
- Assert.That(restoreFiles.Count, Is.GreaterThan(0),
- "Restore partition should contain files to verify");
-
- // Check that all source files exist in restore
- foreach (var relativePath in sourceFiles)
- {
- Assert.That(restoreFiles, Does.Contain(relativePath),
- $"Restored drive should contain file: {relativePath}");
+ // Assumed to fail
}
-
- // Compare file contents byte-by-byte
- foreach (var relativeSourceFile in sourceFiles)
+ catch (UserInformationException)
{
- var sourceFile = Path.Combine(sourcePath, relativeSourceFile);
- var restoreFile = Path.Combine(restorePath, relativeSourceFile);
+ // Assumed to fail
+ }
+ }
- if (File.Exists(restoreFile))
+ // Second attempt: Restore with auto-unmount option should succeed
+ TestContext.Progress.WriteLine("Attempting restore with auto-unmount enabled (should succeed)...");
+
+ options["diskimage-restore-auto-unmount"] = "true";
+ using (var c = new Controller("file://" + TARGETFOLDER, options, null))
+ {
+ var results = c.Restore(["*"]);
+
+ Assert.That(!results.Errors.Any() && results.Warnings.Count() <= 1);
+ }
+ TestContext.Progress.WriteLine($"Restore completed successfully with auto-unmount option");
+
+ // Mount before verification, to make disks online on Windows.
+ sourcePartitions = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true);
+ var restorePartitions = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true);
+
+ // Verify partition table matches
+ VerifyPartitionTableMatches(sourceDrivePath, restoreDrivePath);
+
+ // Verify data matches byte-for-byte
+ var sourcePartition = sourcePartitions.First();
+ var restorePartition = restorePartitions.First();
+ CompareDirectories(sourcePartition, restorePartition);
+ }
+
+ #region Helper Methods
+
+ ///
+ /// Runs a backup operation using the Controller.
+ ///
+ /// The physical drive path to backup.
+ /// If true, treats filesystem as unknown (forces raw block-based backup).
+ /// The backup results.
+ private IBackupResults RunBackup(string physicalDrivePath, bool treatFilesystemAsUnknown = false)
+ {
+ var options = new Dictionary(TestOptions);
+ options["enable-module"] = "diskimage";
+ options["concurrency-fileprocessors"] = "1";
+
+ if (treatFilesystemAsUnknown)
+ {
+ options["diskimage-filesystem-unknown"] = "true";
+ }
+
+ using var c = new Controller("file://" + TARGETFOLDER, options, null);
+ var sourceUrl = $"@/testdisk|diskimage://{physicalDrivePath}";
+ return c.Backup(new[] { sourceUrl });
+ }
+
+ ///
+ /// Runs a restore operation using the Controller.
+ ///
+ /// The physical drive path to restore to.
+ /// The restore results.
+ private IRestoreResults RunRestore(string restoreDrivePath)
+ {
+ var options = new Dictionary(TestOptions);
+ options["restore-path"] = $"@diskimage://{restoreDrivePath}";
+ options["overwrite"] = "true";
+ options["restore-file-processors"] = "1";
+
+ using var c = new Controller("file://" + TARGETFOLDER, options, null);
+ var results = c.Restore(new[] { "*" });
+
+ return results;
+ }
+
+ ///
+ /// Recursively compares two directories for structural and content equality.
+ ///
+ /// The source directory path.
+ /// The restored directory path.
+ private void CompareDirectories(string sourcePath, string restorePath)
+ {
+ var options = new EnumerationOptions
+ {
+ RecurseSubdirectories = true,
+ IgnoreInaccessible = true,
+ AttributesToSkip = FileAttributes.System | FileAttributes.ReparsePoint
+ };
+
+ // Get all files in source, except for system files, which cannot be read directly
+ var sourceFiles = Directory.EnumerateFiles(sourcePath, "*", options)
+ .Select(f => Path.GetRelativePath(sourcePath, f))
+ .Where(f => !string.IsNullOrEmpty(f))
+ .OrderBy(f => f)
+ .ToList();
+ var restoreFiles = Directory.EnumerateFiles(restorePath, "*", options)
+ .Select(f => Path.GetRelativePath(restorePath, f))
+ .Where(f => !string.IsNullOrEmpty(f))
+ .OrderBy(f => f)
+ .ToList();
+
+ Assert.That(sourceFiles.Count, Is.EqualTo(restoreFiles.Count),
+ $"Number of files in source and restore should match. Source: {sourceFiles.Count}, Restore: {restoreFiles.Count}");
+ Assert.That(sourceFiles.Count, Is.GreaterThan(0),
+ "Source partition should contain files to verify");
+ Assert.That(restoreFiles.Count, Is.GreaterThan(0),
+ "Restore partition should contain files to verify");
+
+ // Check that all source files exist in restore
+ foreach (var relativePath in sourceFiles)
+ {
+ Assert.That(restoreFiles, Does.Contain(relativePath),
+ $"Restored drive should contain file: {relativePath}");
+ }
+
+ // Compare file contents byte-by-byte
+ foreach (var relativeSourceFile in sourceFiles)
+ {
+ var sourceFile = Path.Combine(sourcePath, relativeSourceFile);
+ var restoreFile = Path.Combine(restorePath, relativeSourceFile);
+
+ if (File.Exists(restoreFile))
+ {
+ var sourceBytes = File.ReadAllBytes(sourceFile);
+ var restoreBytes = File.ReadAllBytes(restoreFile);
+
+ Assert.That(restoreBytes.Length, Is.EqualTo(sourceBytes.Length),
+ $"File size mismatch for {sourceFile}");
+ Assert.That(restoreBytes, Is.EqualTo(sourceBytes),
+ $"File content mismatch for {sourceFile}");
+ }
+ }
+
+ TestContext.Progress.WriteLine($"Verified {sourceFiles.Count} files match between source and restored drives");
+ }
+
+ ///
+ /// Verifies that the geometry metadata is present and valid.
+ ///
+ /// The backup target URL.
+ /// The options to use when accessing the backup.
+ /// The expected partitions and their sizes.
+ private void VerifyGeometryMetadata(string target, Dictionary options, (FileSystemType, long)[] partitions)
+ {
+ TestContext.Progress.WriteLine("Verifying geometry metadata...");
+
+ using (var c = new Controller("file://" + target, options, null))
+ {
+ var listResults = c.List("*");
+ Assert.That(listResults.Files, Is.Not.Null);
+
+ var geometryFile = listResults.Files.FirstOrDefault(f => f.Path.EndsWith("geometry.json"));
+ Assert.That(geometryFile, Is.Not.Null, "geometry.json should be present in the backup");
+
+ // Restore and parse the geometry.json file
+ var geometryPath = Path.Combine(RESTOREFOLDER, $"geometry.json");
+ try
+ {
+ var restoreOptions = new Dictionary(options)
{
- var sourceBytes = File.ReadAllBytes(sourceFile);
- var restoreBytes = File.ReadAllBytes(restoreFile);
+ ["restore-path"] = RESTOREFOLDER,
+ ["overwrite"] = "true"
+ };
- Assert.That(restoreBytes.Length, Is.EqualTo(sourceBytes.Length),
- $"File size mismatch for {sourceFile}");
- Assert.That(restoreBytes, Is.EqualTo(sourceBytes),
- $"File content mismatch for {sourceFile}");
+ using (var restoreController = new Controller("file://" + target, restoreOptions, null))
+ {
+ var restoreResults = restoreController.Restore(new[] { geometryFile!.Path });
+ TestUtils.AssertResults(restoreResults);
+ }
+
+ // Read and parse the geometry.json
+ if (File.Exists(geometryPath))
+ {
+ var json = File.ReadAllText(geometryPath);
+ var geometry = GeometryMetadata.FromJson(json);
+
+ Assert.That(geometry, Is.Not.Null, "geometry.json should deserialize successfully");
+ Assert.That(geometry!.Version, Is.GreaterThan(0), "Geometry version should be set");
+ Assert.That(geometry.Disk, Is.Not.Null, "Disk geometry should be present");
+ Assert.That(geometry.Disk!.Size, Is.GreaterThan(0), "Disk size should be greater than 0");
+ Assert.That(geometry.Disk.SectorSize, Is.GreaterThan(0), "Sector size should be greater than 0");
+ Assert.That(geometry.PartitionTable, Is.Not.Null, "Partition table should be present");
+ Assert.That(geometry.Partitions, Is.Not.Null, "Partitions list should be present");
+ Assert.That(geometry.Partitions!.Count, Is.GreaterThan(0), "Should have at least one partition");
+
+ TestContext.Progress.WriteLine($"Geometry metadata verified: Disk size={geometry.Disk.Size}, " +
+ $"Sector size={geometry.Disk.SectorSize}, Partitions={geometry.Partitions.Count}, " +
+ $"Table type={geometry.PartitionTable!.Type}");
+
+ for (int i = 0; i < geometry.Partitions.Count; i++)
+ {
+ var partition = geometry.Partitions[i];
+ TestContext.Progress.WriteLine($"Partition {i}: Type={partition.Type}, Start={partition.StartOffset}, Size={partition.Size}");
+
+ Assert.That(partition.Size, Is.GreaterThan(0), $"Partition {i} size should be greater than 0");
+ if (partitions[i].Item2 > 0)
+ {
+ Assert.That(partition.Size, Is.EqualTo(partitions[i].Item2), $"Partition {i} size should match expected");
+ }
+ Assert.That(partition.StartOffset, Is.GreaterThanOrEqualTo(0), $"Partition {i} start offset should be non-negative");
+ Assert.That(partition.Type, Is.Not.Null, $"Partition {i} type should be present");
+ Assert.That(partition.FilesystemType, Is.Not.Null, $"Partition {i} filesystem type should be present");
+ // Type shouldn't be checked until implemented.
+ //Assert.That(partition.FilesystemType, Is.EqualTo(partitions[i].Item1), $"Partition {i} filesystem type should be known");
+ }
}
}
-
- TestContext.Progress.WriteLine($"Verified {sourceFiles.Count} files match between source and restored drives");
- }
-
- ///
- /// Verifies that the geometry metadata is present and valid.
- ///
- /// The backup target URL.
- /// The options to use when accessing the backup.
- /// The expected partitions and their sizes.
- private void VerifyGeometryMetadata(string target, Dictionary options, (FileSystemType, long)[] partitions)
- {
- TestContext.Progress.WriteLine("Verifying geometry metadata...");
-
- using (var c = new Controller("file://" + target, options, null))
+ finally
{
- var listResults = c.List("*");
- Assert.That(listResults.Files, Is.Not.Null);
-
- var geometryFile = listResults.Files.FirstOrDefault(f => f.Path.EndsWith("geometry.json"));
- Assert.That(geometryFile, Is.Not.Null, "geometry.json should be present in the backup");
-
- // Restore and parse the geometry.json file
- var geometryPath = Path.Combine(RESTOREFOLDER, $"geometry.json");
try
{
- var restoreOptions = new Dictionary(options)
- {
- ["restore-path"] = RESTOREFOLDER,
- ["overwrite"] = "true"
- };
-
- using (var restoreController = new Controller("file://" + target, restoreOptions, null))
- {
- var restoreResults = restoreController.Restore(new[] { geometryFile!.Path });
- TestUtils.AssertResults(restoreResults);
- }
-
- // Read and parse the geometry.json
if (File.Exists(geometryPath))
{
- var json = File.ReadAllText(geometryPath);
- var geometry = GeometryMetadata.FromJson(json);
-
- Assert.That(geometry, Is.Not.Null, "geometry.json should deserialize successfully");
- Assert.That(geometry!.Version, Is.GreaterThan(0), "Geometry version should be set");
- Assert.That(geometry.Disk, Is.Not.Null, "Disk geometry should be present");
- Assert.That(geometry.Disk!.Size, Is.GreaterThan(0), "Disk size should be greater than 0");
- Assert.That(geometry.Disk.SectorSize, Is.GreaterThan(0), "Sector size should be greater than 0");
- Assert.That(geometry.PartitionTable, Is.Not.Null, "Partition table should be present");
- Assert.That(geometry.Partitions, Is.Not.Null, "Partitions list should be present");
- Assert.That(geometry.Partitions!.Count, Is.GreaterThan(0), "Should have at least one partition");
-
- TestContext.Progress.WriteLine($"Geometry metadata verified: Disk size={geometry.Disk.Size}, " +
- $"Sector size={geometry.Disk.SectorSize}, Partitions={geometry.Partitions.Count}, " +
- $"Table type={geometry.PartitionTable!.Type}");
-
- for (int i = 0; i < geometry.Partitions.Count; i++)
- {
- var partition = geometry.Partitions[i];
- TestContext.Progress.WriteLine($"Partition {i}: Type={partition.Type}, Start={partition.StartOffset}, Size={partition.Size}");
-
- Assert.That(partition.Size, Is.GreaterThan(0), $"Partition {i} size should be greater than 0");
- if (partitions[i].Item2 > 0)
- {
- Assert.That(partition.Size, Is.EqualTo(partitions[i].Item2), $"Partition {i} size should match expected");
- }
- Assert.That(partition.StartOffset, Is.GreaterThanOrEqualTo(0), $"Partition {i} start offset should be non-negative");
- Assert.That(partition.Type, Is.Not.Null, $"Partition {i} type should be present");
- Assert.That(partition.FilesystemType, Is.Not.Null, $"Partition {i} filesystem type should be present");
- // Type shouldn't be checked until implemented.
- //Assert.That(partition.FilesystemType, Is.EqualTo(partitions[i].Item1), $"Partition {i} filesystem type should be known");
- }
+ File.Delete(geometryPath);
}
}
- finally
+ catch
{
- try
- {
- if (File.Exists(geometryPath))
- {
- File.Delete(geometryPath);
- }
- }
- catch
- {
- // Ignore cleanup errors
- }
+ // Ignore cleanup errors
}
}
}
+ }
- ///
- /// Verifies that the restored partition table matches the source.
- ///
- /// The physical drive path of the source disk.
- /// The physical drive path of the restored disk.
- private void VerifyPartitionTableMatches(string sourceDrivePath, string restoreDrivePath)
+ ///
+ /// Verifies that the restored partition table matches the source.
+ ///
+ /// The physical drive path of the source disk.
+ /// The physical drive path of the restored disk.
+ private void VerifyPartitionTableMatches(string sourceDrivePath, string restoreDrivePath)
+ {
+ TestContext.Progress.WriteLine("Verifying partition table matches...");
+
+ // Get source disk details
+ var sourceTable = _diskHelper.GetPartitionTable(sourceDrivePath);
+ TestContext.Progress.WriteLine("Source disk details:\n" + sourceTable);
+
+ // Get restore disk details
+ var restoreTable = _diskHelper.GetPartitionTable(restoreDrivePath);
+ TestContext.Progress.WriteLine("Restore disk details:\n" + restoreTable);
+
+ Assert.AreEqual(sourceTable.Type, restoreTable.Type, "Partition table types should match");
+ Assert.AreEqual(sourceTable.SectorSize, restoreTable.SectorSize, "Sector sizes should match");
+ Assert.AreEqual(sourceTable.Size, restoreTable.Size, "Size of the disks should match");
+
+ // Retrieve partition information
+ var sourcePartitions = _diskHelper.GetPartitions(sourceDrivePath);
+ var restorePartitions = _diskHelper.GetPartitions(restoreDrivePath);
+
+ // Verify partition counts match
+ Assert.AreEqual(sourcePartitions.Length, restorePartitions.Length, "Number of partitions should match");
+
+ // Verify partitions match
+ for (int i = 0; i < sourcePartitions.Length; i++)
{
- TestContext.Progress.WriteLine("Verifying partition table matches...");
-
- // Get source disk details
- var sourceTable = _diskHelper.GetPartitionTable(sourceDrivePath);
- TestContext.Progress.WriteLine("Source disk details:\n" + sourceTable);
-
- // Get restore disk details
- var restoreTable = _diskHelper.GetPartitionTable(restoreDrivePath);
- TestContext.Progress.WriteLine("Restore disk details:\n" + restoreTable);
-
- Assert.AreEqual(sourceTable.Type, restoreTable.Type, "Partition table types should match");
- Assert.AreEqual(sourceTable.SectorSize, restoreTable.SectorSize, "Sector sizes should match");
- Assert.AreEqual(sourceTable.Size, restoreTable.Size, "Size of the disks should match");
-
- // Retrieve partition information
- var sourcePartitions = _diskHelper.GetPartitions(sourceDrivePath);
- var restorePartitions = _diskHelper.GetPartitions(restoreDrivePath);
-
- // Verify partition counts match
- Assert.AreEqual(sourcePartitions.Length, restorePartitions.Length, "Number of partitions should match");
-
- // Verify partitions match
- for (int i = 0; i < sourcePartitions.Length; i++)
- {
- Assert.AreEqual(sourcePartitions[i].Type, restorePartitions[i].Type, $"Partition {i} types should match");
- Assert.AreEqual(sourcePartitions[i].StartOffset, restorePartitions[i].StartOffset, $"Partition {i} offsets should match");
- Assert.AreEqual(sourcePartitions[i].Size, restorePartitions[i].Size, $"Partition {i} sizes should match");
- }
-
- TestContext.Progress.WriteLine($"Partition table verified.");
+ Assert.AreEqual(sourcePartitions[i].Type, restorePartitions[i].Type, $"Partition {i} types should match");
+ Assert.AreEqual(sourcePartitions[i].StartOffset, restorePartitions[i].StartOffset, $"Partition {i} offsets should match");
+ Assert.AreEqual(sourcePartitions[i].Size, restorePartitions[i].Size, $"Partition {i} sizes should match");
}
- #endregion
+ TestContext.Progress.WriteLine($"Partition table verified.");
}
+
+ #endregion
+
+ #region Filesystem-Aware Integration Tests
+
+ ///
+ /// Tests that incremental backup with filesystem awareness skips unchanged blocks.
+ /// Creates a disk with the specified filesystem, performs initial backup, modifies one file,
+ /// performs second backup, and verifies only changed blocks were processed.
+ ///
+ [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.FAT32)]
+ [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.Unknown)]
+ [Category("DiskImage")]
+ [Category("DiskImageFileSystem")]
+ public async Task Test_FileSystem_IncrementalBackup_UnchangedBlocks_Skipped(int size, PartitionTableType tableType, FileSystemType fsType)
+ {
+ await TestContext.Progress.WriteLineAsync($"Test: {fsType} Incremental Backup - Unchanged Blocks Skipped");
+
+ // When testing Unknown filesystem, format source as FAT32 to allow test data generation,
+ // but treat it as unknown during backup
+ var sourceFsType = fsType == FileSystemType.Unknown ? FileSystemType.FAT32 : fsType;
+ var treatFilesystemAsUnknown = fsType == FileSystemType.Unknown;
+
+ // Create source disk with specified partition
+ var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, size);
+ var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, tableType, [(sourceFsType, 0)]);
+ await TestContext.Progress.WriteLineAsync($"Source disk created at: {_sourceImagePath}");
+
+ // Generate initial test data
+ await ToolTests.GenerateTestData(sourcePartitions.First(), 10, 3, 1, 1024);
+ _diskHelper.FlushDisk(sourceDrivePath);
+ _diskHelper.Unmount(sourceDrivePath);
+ await TestContext.Progress.WriteLineAsync("Initial test data generated");
+
+ // First backup
+ var firstBackupResults = RunBackup(sourceDrivePath, treatFilesystemAsUnknown);
+
+ // When treating filesystem as unknown, the option may not be recognized by the Controller
+ // so we only check for errors, not warnings
+ if (treatFilesystemAsUnknown)
+ {
+ Assert.That(firstBackupResults.Errors.Count(), Is.EqualTo(0),
+ "Backup should have no errors");
+ }
+ else
+ {
+ TestUtils.AssertResults(firstBackupResults);
+ }
+
+ var firstBackupSize = firstBackupResults.SizeOfOpenedFiles;
+ await TestContext.Progress.WriteLineAsync($"First backup completed: {firstBackupResults.OpenedFiles} files opened, {firstBackupSize} bytes");
+
+ // Verify first backup processed the expected amount of data
+ // With FAT32 awareness, unallocated blocks are returned as zeros (no disk read)
+ Assert.That(firstBackupResults.SizeOfOpenedFiles, Is.GreaterThan(0),
+ "First backup should have opened and processed files");
+
+ // Create restore disk for initial restore verification
+ var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
+ _diskHelper.InitializeDisk(restoreDrivePath, tableType, []);
+ _diskHelper.Unmount(restoreDrivePath);
+ await TestContext.Progress.WriteLineAsync($"Restore disk created at: {_restoreImagePath}");
+
+ var restoreResults = RunRestore(restoreDrivePath);
+ TestUtils.AssertResults(restoreResults);
+ await TestContext.Progress.WriteLineAsync("Initial restore completed successfully");
+
+ // Reattach and verify initial restore
+ sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
+ restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
+
+ var fsTypes = new[] { sourceFsType };
+ var sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
+ var restoreMounted = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
+
+ CompareDirectories(sourceMounted.First(), restoreMounted.First());
+ await TestContext.Progress.WriteLineAsync("Initial restore verified successfully");
+
+ // Unmount both disks to prepare for modification
+ _diskHelper.Unmount(sourceDrivePath);
+ _diskHelper.Unmount(restoreDrivePath);
+ await TestContext.Progress.WriteLineAsync("Both disks unmounted");
+
+ // Remount source drive in write-enabled mode
+ sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: false);
+ sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: false, fileSystemTypes: fsTypes);
+ await TestContext.Progress.WriteLineAsync("Source disk remounted in write mode");
+
+ // Modify a few files on the source - create new files and modify existing ones
+ var sourcePartitionPath = sourceMounted.First();
+ var modifiedFiles = new List();
+
+ // Create a new directory with some new files
+ var newDirPath = Path.Combine(sourcePartitionPath, "incremental_new_files");
+ Directory.CreateDirectory(newDirPath);
+ for (int i = 0; i < 3; i++)
+ {
+ var newFilePath = Path.Combine(newDirPath, $"new_file_{i}.txt");
+ var content = $"This is new content for incremental backup test - file {i} - {Guid.NewGuid()}";
+ await File.WriteAllTextAsync(newFilePath, content);
+ // Set a recent modification time to ensure the backup detects the change
+ File.SetLastWriteTimeUtc(newFilePath, DateTime.UtcNow);
+ modifiedFiles.Add(newFilePath);
+ }
+ // Also update directory timestamp
+ Directory.SetLastWriteTimeUtc(newDirPath, DateTime.UtcNow);
+ await TestContext.Progress.WriteLineAsync($"Created {modifiedFiles.Count} new files in {newDirPath}");
+
+ // Modify an existing file if one exists
+ var existingFiles = Directory.GetFiles(sourcePartitionPath, "*.txt", SearchOption.AllDirectories)
+ .Where(f => !f.Contains("incremental_new_files"))
+ .ToArray();
+ if (existingFiles.Length > 0)
+ {
+ var fileToModify = existingFiles[0];
+ var originalContent = await File.ReadAllTextAsync(fileToModify);
+ var modifiedContent = originalContent + $"\n\nModified for incremental backup test - {Guid.NewGuid()}";
+ await File.WriteAllTextAsync(fileToModify, modifiedContent);
+ // Update modification time to ensure backup detects the change
+ File.SetLastWriteTimeUtc(fileToModify, DateTime.UtcNow.AddSeconds(1));
+ modifiedFiles.Add(fileToModify);
+ await TestContext.Progress.WriteLineAsync($"Modified existing file: {fileToModify}");
+ }
+
+ // Flush and unmount source after modifications
+ _diskHelper.FlushDisk(sourceDrivePath);
+ _diskHelper.Unmount(sourceDrivePath);
+ await TestContext.Progress.WriteLineAsync("Modifications complete, source disk unmounted");
+
+ // Re-attach the source disk for backup (disk needs to be attached for backup to access it)
+ sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
+ await TestContext.Progress.WriteLineAsync($"Source disk re-attached for second backup at: {sourceDrivePath}");
+
+ // Second backup (incremental)
+ var secondBackupResults = RunBackup(sourceDrivePath, treatFilesystemAsUnknown);
+
+ // When treating filesystem as unknown, the option may not be recognized by the Controller
+ // so we only check for errors, not warnings
+ if (treatFilesystemAsUnknown)
+ {
+ Assert.That(secondBackupResults.Errors.Count(), Is.EqualTo(0),
+ "Backup should have no errors");
+ }
+ else
+ {
+ TestUtils.AssertResults(secondBackupResults);
+ }
+
+ var secondBackupSize = secondBackupResults.SizeOfOpenedFiles;
+ await TestContext.Progress.WriteLineAsync($"Second backup completed: {secondBackupResults.OpenedFiles} files opened, {secondBackupSize} bytes examined, {secondBackupResults.SizeOfAddedFiles} bytes added");
+
+ if (fsType is not FileSystemType.Unknown)
+ {
+ // Verify the second backup processed significantly fewer files than the first
+ // The second backup should only process modified/new files, not all files
+ Assert.That(secondBackupResults.OpenedFiles, Is.LessThan(firstBackupResults.OpenedFiles),
+ "Incremental backup should open fewer files than the initial backup");
+ Assert.That(secondBackupResults.SizeOfOpenedFiles, Is.LessThan(firstBackupResults.SizeOfOpenedFiles),
+ "Incremental backup should examine less data than the initial backup");
+ }
+
+ // Also verify that the second backup actually modified some files
+ Assert.That(secondBackupResults.ModifiedFiles, Is.GreaterThan(0),
+ "Incremental backup should have added new or modified files");
+
+ // Calculate and log the reduction ratio
+ var fileReductionRatio = (double)secondBackupResults.OpenedFiles / firstBackupResults.OpenedFiles;
+ var sizeReductionRatio = (double)secondBackupResults.SizeOfOpenedFiles / firstBackupResults.SizeOfOpenedFiles;
+ await TestContext.Progress.WriteLineAsync($"File reduction: {fileReductionRatio:P2} ({secondBackupResults.OpenedFiles}/{firstBackupResults.OpenedFiles})");
+ await TestContext.Progress.WriteLineAsync($"Size reduction: {sizeReductionRatio:P2} ({secondBackupResults.SizeOfOpenedFiles}/{firstBackupResults.SizeOfOpenedFiles})");
+
+ // Restore the incremental backup to a fresh disk
+ _diskHelper.CleanupDisk(_restoreImagePath);
+ DiskImageTestHelpers.SafeDeleteFile(_restoreImagePath);
+
+ restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
+ _diskHelper.InitializeDisk(restoreDrivePath, tableType, []);
+ _diskHelper.Unmount(restoreDrivePath);
+ await TestContext.Progress.WriteLineAsync($"Fresh restore disk created for incremental restore");
+
+ var incrementalRestoreResults = RunRestore(restoreDrivePath);
+ TestUtils.AssertResults(incrementalRestoreResults);
+ await TestContext.Progress.WriteLineAsync("Incremental restore completed successfully");
+
+ // Reattach and verify the incremental restore matches the modified source
+ sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
+ restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
+
+ sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
+ restoreMounted = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
+
+ CompareDirectories(sourceMounted.First(), restoreMounted.First());
+
+ await TestContext.Progress.WriteLineAsync($"{fsType} incremental backup and restore verified successfully");
+ }
+
+ ///
+ /// Tests that unallocated space compresses well due to zero-block deduplication.
+ /// Creates a disk with the specified filesystem and sparse data (few files, lots of free space),
+ /// backs it up, and verifies the backup size is significantly smaller than the disk size.
+ ///
+ [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.FAT32)]
+ [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.Unknown)]
+ [Category("DiskImage")]
+ [Category("DiskImageFileSystem")]
+ public async Task Test_FileSystem_UnallocatedSpace_CompressesWell(int size, PartitionTableType tableType, FileSystemType fsType)
+ {
+ await TestContext.Progress.WriteLineAsync($"Test: {fsType} Unallocated Space Compression");
+
+ // When testing Unknown filesystem, format source as FAT32 to allow test data generation,
+ // but treat it as unknown during backup
+ var sourceFsType = fsType == FileSystemType.Unknown ? FileSystemType.FAT32 : fsType;
+ var treatFilesystemAsUnknown = fsType == FileSystemType.Unknown;
+
+ // Create source disk with specified partition
+ var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, size);
+ var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, tableType, [(sourceFsType, 0)]);
+ await TestContext.Progress.WriteLineAsync($"Source disk created at: {_sourceImagePath}");
+
+ // Generate minimal test data (sparse data - only a few small files)
+ // This leaves most of the 50MB as unallocated space
+ await ToolTests.GenerateTestData(sourcePartitions.First(), 3, 0, 0, 512);
+ _diskHelper.FlushDisk(sourceDrivePath);
+ _diskHelper.Unmount(sourceDrivePath);
+ await TestContext.Progress.WriteLineAsync("Sparse test data generated (few files, lots of free space)");
+
+ // Backup
+ var backupResults = RunBackup(sourceDrivePath, treatFilesystemAsUnknown);
+ // When treating filesystem as unknown, the option may not be recognized by the Controller
+ // so we only check for errors, not warnings
+ if (treatFilesystemAsUnknown)
+ {
+ Assert.That(backupResults.Errors.Count(), Is.EqualTo(0),
+ "Backup should have no errors");
+ }
+ else
+ {
+ TestUtils.AssertResults(backupResults);
+ }
+ await TestContext.Progress.WriteLineAsync($"Backup completed: {backupResults.SizeOfOpenedFiles} bytes examined");
+
+ // Calculate the actual backup size from the target folder
+ var backupFiles = Directory.GetFiles(TARGETFOLDER, "*", SearchOption.AllDirectories);
+ long actualBackupSize = backupFiles.Sum(f => new FileInfo(f).Length);
+ await TestContext.Progress.WriteLineAsync($"Actual backup size on disk: {actualBackupSize} bytes");
+
+ // The backup should be significantly smaller than the full disk size
+ // With zero-block deduplication, unallocated clusters return zeros which compress very well
+ var compressionRatio = (double)actualBackupSize / size;
+ await TestContext.Progress.WriteLineAsync($"Compression ratio: {compressionRatio:P2} ({actualBackupSize}/{size})");
+
+ // Backup should be less than 50% of disk size due to zero deduplication
+ // (This is a conservative estimate - actual ratio should be much better)
+ Assert.That(compressionRatio, Is.LessThan(0.5),
+ "Backup with sparse data should be significantly smaller than full disk size due to zero-block deduplication");
+
+ // Create restore disk and verify restore works
+ var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
+ _diskHelper.InitializeDisk(restoreDrivePath, tableType, []);
+ _diskHelper.Unmount(restoreDrivePath);
+
+ var restoreResults = RunRestore(restoreDrivePath);
+ TestUtils.AssertResults(restoreResults);
+
+ // Reattach and verify
+ sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
+ restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
+
+ var fsTypes = new[] { fsType };
+ var sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
+ var restoreMounted = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
+
+ CompareDirectories(sourceMounted.First(), restoreMounted.First());
+
+ await TestContext.Progress.WriteLineAsync($"{fsType} unallocated space compression verified successfully");
+ }
+
+ ///
+ /// Tests that a completely full disk has all blocks marked as allocated.
+ /// Creates a disk filled with data and verifies the backup reads the full disk.
+ ///
+ [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.FAT32)]
+ [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.Unknown)]
+ [Category("DiskImage")]
+ [Category("DiskImageFileSystem")]
+ public async Task Test_FileSystem_FullDisk_AllBlocksAllocated(int size, PartitionTableType tableType, FileSystemType fsType)
+ {
+ await TestContext.Progress.WriteLineAsync($"Test: {fsType} Full Disk - All Blocks Allocated");
+
+ // When testing Unknown filesystem, format source as FAT32 to allow test data generation,
+ // but treat it as unknown during backup
+ var sourceFsType = fsType == FileSystemType.Unknown ? FileSystemType.FAT32 : fsType;
+ var treatFilesystemAsUnknown = fsType == FileSystemType.Unknown;
+
+ // Create source disk with specified partition (must be at least 32MB for FAT32)
+ var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, size);
+ var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, tableType, [(sourceFsType, 0)]);
+ await TestContext.Progress.WriteLineAsync($"Source disk created at: {_sourceImagePath} ({size} bytes)");
+
+ // Fill the disk with data (create enough files to fill most of the space)
+ var partitionPath = sourcePartitions.First();
+
+ // Generate many files to fill the disk
+ // Using larger files to fill space quickly
+ // Adjust batch count based on filesystem type - Unknown (raw) mode may need different amounts
+ var batchCount = fsType == FileSystemType.Unknown ? 5 : 10;
+ for (int batch = 0; batch < batchCount; batch++)
+ {
+ await ToolTests.GenerateTestData(Path.Combine(partitionPath, $"batch_{batch}"), 10, 0, 0, 4096);
+ }
+ _diskHelper.FlushDisk(sourceDrivePath);
+ _diskHelper.Unmount(sourceDrivePath);
+ await TestContext.Progress.WriteLineAsync("Disk filled with data");
+
+ // Backup
+ var backupResults = RunBackup(sourceDrivePath, treatFilesystemAsUnknown);
+
+ // When treating filesystem as unknown, the option may not be recognized by the Controller
+ // so we only check for errors, not warnings
+ if (treatFilesystemAsUnknown)
+ {
+ Assert.That(backupResults.Errors.Count(), Is.EqualTo(0),
+ "Backup should have no errors");
+ }
+ else
+ {
+ TestUtils.AssertResults(backupResults);
+ }
+
+ await TestContext.Progress.WriteLineAsync($"Backup completed: {backupResults.SizeOfOpenedFiles} bytes examined, {backupResults.SizeOfAddedFiles} bytes added");
+
+ // With a full disk, most blocks should be allocated
+ // The backup should have examined a significant portion of the disk
+ // Note: We examine files (blocks), and with a full disk, SizeOfOpenedFiles
+ // should be close to the partition size
+ Assert.That(backupResults.SizeOfOpenedFiles, Is.GreaterThan(size * 0.5),
+ "Full disk backup should examine most of the disk size");
+
+ // Create restore disk and verify restore
+ var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
+ _diskHelper.InitializeDisk(restoreDrivePath, tableType, []);
+ _diskHelper.Unmount(restoreDrivePath);
+
+ var restoreResults = RunRestore(restoreDrivePath);
+ TestUtils.AssertResults(restoreResults);
+
+ // Reattach and verify
+ sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
+ restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
+
+ var fsTypes = new[] { fsType };
+ var sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
+ var restoreMounted = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
+
+ CompareDirectories(sourceMounted.First(), restoreMounted.First());
+
+ await TestContext.Progress.WriteLineAsync($"{fsType} full disk backup verified successfully");
+ }
+
+ #endregion
}
diff --git a/Duplicati/UnitTest/DiskImage/DiskImageUnitTests.cs b/Duplicati/UnitTest/DiskImage/DiskImageUnitTests.cs
deleted file mode 100644
index 65c732f5b..000000000
--- a/Duplicati/UnitTest/DiskImage/DiskImageUnitTests.cs
+++ /dev/null
@@ -1,5543 +0,0 @@
-// Copyright (C) 2025, The Duplicati Team
-// https://duplicati.com, hello@duplicati.com
-//
-// Permission is hereby granted, free of charge, to any person obtaining a
-// copy of this software and associated documentation files (the "Software"),
-// to deal in the Software without restriction, including without limitation
-// the rights to use, copy, modify, merge, publish, distribute, sublicense,
-// and/or sell copies of the Software, and to permit persons to whom the
-// Software is furnished to do so, subject to the following conditions:
-//
-// The above copyright notice and this permission notice shall be included in
-// all copies or substantial portions of the Software.
-//
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-// DEALINGS IN THE SOFTWARE.
-
-using System;
-using System.Buffers;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Reflection;
-using System.Threading;
-using System.Threading.Tasks;
-using Duplicati.Library.Interface;
-using Duplicati.UnitTest.DiskImage;
-using Duplicati.Proprietary.DiskImage;
-using Duplicati.Proprietary.DiskImage.Disk;
-using Duplicati.Proprietary.DiskImage.Filesystem;
-using Duplicati.Proprietary.DiskImage.General;
-using Duplicati.Proprietary.DiskImage.Partition;
-using NUnit.Framework;
-using Assert = NUnit.Framework.Legacy.ClassicAssert;
-using StringAssert = NUnit.Framework.Legacy.StringAssert;
-
-#nullable enable
-
-namespace Duplicati.UnitTest.DiskImage
-{
- ///
- /// Unit tests for the DiskImage module's internal components.
- /// These tests focus on individual component testing with a strong emphasis
- /// on unaligned reads/writes and cross-platform compatibility.
- ///
- [TestFixture]
- [Category("DiskImageUnit")]
- [Platform("Win,MacOsX,Linux")]
- public class DiskImageUnitTests : BasicSetupHelper
- {
- // Class-level read-only disks (set up once for entire test class)
- private static IDiskImageHelper? s_diskHelper;
-
- // GPT disk with 2 FAT32 partitions
- private static string s_gptDiskPath = "";
- private static string s_gptDiskIdentifier = "";
- private static IRawDisk? s_gptRawDisk;
- private static long s_gptPartition1Offset;
- private static long s_gptPartition1Size;
- private static long s_gptPartition2Offset;
- private static long s_gptPartition2Size;
-
- // MBR disk with 2 FAT32 partitions
- private static string s_mbrDiskPath = "";
- private static string s_mbrDiskIdentifier = "";
- private static IRawDisk? s_mbrRawDisk;
- private static long s_mbrPartition1Offset;
- private static long s_mbrPartition1Size;
- private static long s_mbrPartition2Offset;
- private static long s_mbrPartition2Size;
-
- // Writable disk for tests that need to write (re-initialized before each test)
- private static string s_writableDiskPath = "";
- private static string s_writableDiskIdentifier = "";
- private static IRawDisk? s_writableRawDisk;
-
- // Per-test instance members
- private IRawDisk _writableRawDisk = null!;
-
- private const long MiB = 1024 * 1024;
-
- #region Class-level Setup and Teardown
-
- ///
- /// One-time setup for the entire test class.
- /// Creates read-only GPT and MBR disks with 2 FAT32 partitions each.
- ///
- [OneTimeSetUp]
- public void OneTimeSetUp()
- {
- base.BasicHelperSetUp();
-
- // Create the appropriate disk image helper for the current platform
- s_diskHelper = DiskImageHelperFactory.Create();
-
- // Check for admin privileges
- if (!s_diskHelper.HasRequiredPrivileges())
- {
- Assert.Ignore("DiskImage tests require administrator privileges");
- }
-
- var extension = DiskImageTestHelpers.GetPlatformDiskImageExtension();
-
- // Create GPT disk with 2 FAT32 partitions
- s_gptDiskPath = Path.Combine(BASEFOLDER, $"duplicati_gpt_class_test.{extension}");
- s_gptDiskIdentifier = DiskImageTestHelpers.CreateDiskWithPartitions(
- s_diskHelper, s_gptDiskPath, 100 * MiB, PartitionTableType.GPT, [(FileSystemType.FAT32, 50 * MiB), (FileSystemType.FAT32, 0)]);
-
- // Get partition info from GPT disk
- var gptPartitions = s_diskHelper.GetPartitions(s_gptDiskIdentifier);
- if (gptPartitions.Length >= 2)
- {
- s_gptPartition1Offset = gptPartitions[0].StartOffset;
- s_gptPartition1Size = gptPartitions[0].Size;
- s_gptPartition2Offset = gptPartitions[1].StartOffset;
- s_gptPartition2Size = gptPartitions[1].Size;
- }
-
- // Create and initialize raw disk for GPT (read-only)
- s_gptRawDisk = DiskImageTestHelpers.CreateRawDiskForIdentifier(s_gptDiskIdentifier);
- if (!s_gptRawDisk.InitializeAsync(true, CancellationToken.None).Result)
- throw new InvalidOperationException("Failed to initialize GPT raw disk");
-
- // Fill GPT partitions with well-known test data
- DiskImageTestHelpers.FillPartitionWithTestDataAsync(s_gptRawDisk, s_gptPartition1Offset, s_gptPartition1Size, CancellationToken.None).Wait();
- DiskImageTestHelpers.FillPartitionWithTestDataAsync(s_gptRawDisk, s_gptPartition2Offset, s_gptPartition2Size, CancellationToken.None).Wait();
- s_diskHelper.FlushDisk(s_gptDiskIdentifier);
-
- // Create MBR disk with 2 FAT32 partitions
- s_mbrDiskPath = Path.Combine(BASEFOLDER, $"duplicati_mbr_class_test.{extension}");
- s_mbrDiskIdentifier = DiskImageTestHelpers.CreateDiskWithPartitions(
- s_diskHelper, s_mbrDiskPath, 100 * MiB, PartitionTableType.MBR, [(FileSystemType.FAT32, 50 * MiB), (FileSystemType.FAT32, 0)]);
-
- // Get partition info from MBR disk
- var mbrPartitions = s_diskHelper.GetPartitions(s_mbrDiskIdentifier);
- if (mbrPartitions.Length >= 2)
- {
- s_mbrPartition1Offset = mbrPartitions[0].StartOffset;
- s_mbrPartition1Size = mbrPartitions[0].Size;
- s_mbrPartition2Offset = mbrPartitions[1].StartOffset;
- s_mbrPartition2Size = mbrPartitions[1].Size;
- }
-
- // Create and initialize raw disk for MBR (read-only)
- s_mbrRawDisk = DiskImageTestHelpers.CreateRawDiskForIdentifier(s_mbrDiskIdentifier);
- if (!s_mbrRawDisk.InitializeAsync(true, CancellationToken.None).Result)
- throw new InvalidOperationException("Failed to initialize MBR raw disk");
-
- // Fill MBR partitions with well-known test data
- DiskImageTestHelpers.FillPartitionWithTestDataAsync(s_mbrRawDisk, s_mbrPartition1Offset, s_mbrPartition1Size, CancellationToken.None).Wait();
- DiskImageTestHelpers.FillPartitionWithTestDataAsync(s_mbrRawDisk, s_mbrPartition2Offset, s_mbrPartition2Size, CancellationToken.None).Wait();
- s_diskHelper.FlushDisk(s_mbrDiskIdentifier);
-
- // Create writable disk path (will be created per-test)
- s_writableDiskPath = Path.Combine(BASEFOLDER, $"duplicati_writable_class_test.{extension}");
-
- // Create new writable disk (100 MiB, uninitialized)
- s_writableDiskIdentifier = s_diskHelper.CreateDisk(s_writableDiskPath, 100 * MiB);
- s_diskHelper.Unmount(s_writableDiskIdentifier);
-
- // Create raw disk interface (with write access)
- s_writableRawDisk = DiskImageTestHelpers.CreateRawDiskForIdentifier(s_writableDiskIdentifier);
- if (!s_writableRawDisk.InitializeAsync(true, CancellationToken.None).Result)
- throw new InvalidOperationException("Failed to initialize writable raw disk");
- }
-
- ///
- /// One-time teardown for the entire test class.
- /// Cleans up all class-level disk resources.
- ///
- [OneTimeTearDown]
- public void OneTimeTearDown()
- {
- // Dispose read-only raw disks
- s_gptRawDisk?.Dispose();
- s_mbrRawDisk?.Dispose();
- s_writableRawDisk?.Dispose();
-
- // Cleanup class-level disks - this properly detaches and deletes the disk images
- // Using CleanupDisk ensures the disk is properly detached before the file is deleted
- if (s_diskHelper != null)
- {
- s_diskHelper.CleanupDisk(s_gptDiskPath, s_gptDiskIdentifier);
- s_diskHelper.CleanupDisk(s_mbrDiskPath, s_mbrDiskIdentifier);
- s_diskHelper.CleanupDisk(s_writableDiskPath, s_writableDiskIdentifier);
- }
-
- s_diskHelper?.Dispose();
- }
-
- #endregion
-
- #region Per-test Setup and Teardown
-
- ///
- /// Sets up the test environment before each test that needs a writable disk.
- ///
- [SetUp]
- public void SetUp()
- {
- if (s_writableRawDisk != null)
- {
- _writableRawDisk = s_writableRawDisk;
- }
- }
-
- ///
- /// Cleans up after each test.
- ///
- [TearDown]
- public void TearDown()
- {
- // No longer unmounting per-test to improve performance
- }
-
- #endregion
-
- #region IRawDisk Sector-Aligned Tests
-
- [Test]
- public async Task Test_RawDisk_ReadSector_ReturnsNonEmptyData()
- {
- var sectorSize = s_gptRawDisk!.SectorSize;
- using var stream = await s_gptRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None);
- var buffer = new byte[sectorSize];
- var bytesRead = await stream.ReadAsync(buffer.AsMemory(), CancellationToken.None);
- Assert.AreEqual(sectorSize, bytesRead, "Should have read 1 sector.");
- bool hasData = buffer.Any(x => x != 0);
- Assert.IsTrue(hasData, "Sector 0 should contain data.");
- }
-
- [Test]
- public async Task Test_RawDisk_ReadBytes_ReturnsData()
- {
- var sectorSize = s_gptRawDisk!.SectorSize;
- using var stream = await s_gptRawDisk.ReadBytesAsync(sectorSize, sectorSize, CancellationToken.None);
- Assert.AreEqual(sectorSize, stream.Length, "Should have read the correct amount of bytes.");
- }
-
- [Test]
- public async Task Test_RawDisk_ReadBytesAsync_CallerProvidedBuffer()
- {
- var sectorSize = s_gptRawDisk!.SectorSize;
- var buffer = new byte[sectorSize];
- var bytesRead = await s_gptRawDisk.ReadBytesAsync(0, buffer.AsMemory(), CancellationToken.None);
- Assert.AreEqual(sectorSize, bytesRead, "Should have read the correct amount of bytes.");
- }
-
- [Test]
- public async Task Test_RawDisk_WriteSectors_DataMatches()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var writeBuffer = new byte[sectorSize];
- new Random().NextBytes(writeBuffer);
- await _writableRawDisk.WriteSectorsAsync(1, writeBuffer, CancellationToken.None);
- using var readStream = await _writableRawDisk.ReadSectorsAsync(1, 1, CancellationToken.None);
- var readBuffer = new byte[sectorSize];
- var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
- Assert.AreEqual(sectorSize, bytesRead, "Should have read the correct amount of bytes.");
- Assert.AreEqual(writeBuffer, readBuffer, "Data should match.");
- }
-
- [Test]
- public async Task Test_RawDisk_WriteBytes_DataMatches()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var writeBuffer = new byte[sectorSize];
- new Random().NextBytes(writeBuffer);
- await _writableRawDisk.WriteBytesAsync(sectorSize, writeBuffer, CancellationToken.None);
- using var readStream = await _writableRawDisk.ReadBytesAsync(sectorSize, sectorSize, CancellationToken.None);
- var readBuffer = new byte[sectorSize];
- var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
- Assert.AreEqual(sectorSize, bytesRead, "Should have read the correct amount of bytes.");
- Assert.AreEqual(writeBuffer, readBuffer, "Data should match.");
- }
-
- [Test]
- public async Task Test_RawDisk_WriteBytes_Memory()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var writeBuffer = new byte[sectorSize];
- new Random().NextBytes(writeBuffer);
- await _writableRawDisk.WriteBytesAsync(sectorSize, writeBuffer.AsMemory(), CancellationToken.None);
- using var readStream = await _writableRawDisk.ReadBytesAsync(sectorSize, sectorSize, CancellationToken.None);
- var readBuffer = new byte[sectorSize];
- var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
- Assert.AreEqual(sectorSize, bytesRead, "Should have read the correct amount of bytes.");
- Assert.AreEqual(writeBuffer, readBuffer, "Data should match.");
- }
-
- #endregion
-
- #region IRawDisk Unaligned Read Tests
-
- [Test]
- public async Task Test_RawDisk_ReadUnalignedOffset_ReturnsCorrectData()
- {
- // Use writable disk for this test
- var sectorSize = _writableRawDisk.SectorSize;
-
- // Write known pattern at sector 1
- var writeBuffer = new byte[sectorSize];
- for (int i = 0; i < sectorSize; i++)
- writeBuffer[i] = (byte)(i & 0xFF);
-
- await _writableRawDisk.WriteSectorsAsync(1, writeBuffer, CancellationToken.None);
-
- // Read at offset = sector_size + 1 (unaligned)
- var offset = sectorSize + 1;
- var length = sectorSize - 2;
-
- using var stream = await _writableRawDisk.ReadBytesAsync(offset, length, CancellationToken.None);
- var readBuffer = new byte[length];
- var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
-
- Assert.AreEqual(length, bytesRead, "Should have read the requested length.");
- for (int i = 0; i < length; i++)
- Assert.AreEqual((byte)((i + 1) & 0xFF), readBuffer[i], $"Byte at position {i} should match.");
- }
-
- [Test]
- public async Task Test_RawDisk_ReadUnalignedLength_ReturnsCorrectData()
- {
- var sectorSize = _writableRawDisk.SectorSize;
-
- // Write known pattern at sector 0
- var writeBuffer = new byte[sectorSize];
- for (int i = 0; i < sectorSize; i++)
- writeBuffer[i] = (byte)(i & 0xFF);
-
- await _writableRawDisk.WriteSectorsAsync(0, writeBuffer, CancellationToken.None);
-
- // Read with unaligned length (sectorSize - 1)
- var length = sectorSize - 1;
- using var stream = await _writableRawDisk.ReadBytesAsync(0, length, CancellationToken.None);
- var readBuffer = new byte[length];
- var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
-
- Assert.AreEqual(length, bytesRead, "Should have read the requested length.");
- for (int i = 0; i < length; i++)
- Assert.AreEqual((byte)(i & 0xFF), readBuffer[i], $"Byte at position {i} should match.");
- }
-
- [Test]
- public async Task Test_RawDisk_ReadShortLength_ReturnsCorrectData()
- {
- var sectorSize = _writableRawDisk.SectorSize;
-
- // Write known pattern at sector 0
- var writeBuffer = new byte[sectorSize];
- for (int i = 0; i < sectorSize; i++)
- writeBuffer[i] = (byte)(i & 0xFF);
-
- await _writableRawDisk.WriteSectorsAsync(0, writeBuffer, CancellationToken.None);
-
- // Read short length
- var length = sectorSize - 1;
- using var stream = await _writableRawDisk.ReadBytesAsync(0, length, CancellationToken.None);
- var readBuffer = new byte[length];
- var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
-
- Assert.AreEqual(length, bytesRead, "Should have read the requested short length.");
- for (int i = 0; i < length; i++)
- Assert.AreEqual((byte)(i & 0xFF), readBuffer[i], $"Byte at position {i} should match.");
- }
-
- [Test]
- public async Task Test_RawDisk_ReadStraddlingSectors_ReturnsCorrectData()
- {
- var sectorSize = _writableRawDisk.SectorSize;
-
- // Write different patterns at sectors 0 and 1
- var sector0Data = new byte[sectorSize];
- var sector1Data = new byte[sectorSize];
- for (int i = 0; i < sectorSize; i++)
- {
- sector0Data[i] = (byte)(i | 0xF0);
- sector1Data[i] = (byte)(i | 0x0F);
- }
-
- await _writableRawDisk.WriteSectorsAsync(0, sector0Data, CancellationToken.None);
- await _writableRawDisk.WriteSectorsAsync(1, sector1Data, CancellationToken.None);
-
- // Read at half-sector offset with full sector length
- var offset = sectorSize / 2;
- using var stream = await _writableRawDisk.ReadBytesAsync(offset, sectorSize, CancellationToken.None);
- var readBuffer = new byte[sectorSize];
- var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
-
- Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector.");
-
- // First half should be from end of sector 0
- for (int i = 0; i < sectorSize / 2; i++)
- {
- var expected = (byte)((sectorSize / 2 + i) | 0xF0);
- Assert.AreEqual(expected, readBuffer[i], $"Byte at position {i} should match sector 0 data.");
- }
-
- // Second half should be from start of sector 1
- for (int i = sectorSize / 2; i < sectorSize; i++)
- {
- var expected = (byte)((i - sectorSize / 2) | 0x0F);
- Assert.AreEqual(expected, readBuffer[i], $"Byte at position {i} should match sector 1 data.");
- }
- }
-
- [Test]
- public async Task Test_RawDisk_ReadNearEndOfDisk_ReturnsCorrectData()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var diskSize = _writableRawDisk.Size;
-
- // Calculate the last sector and write data there
- var lastSector = (diskSize / sectorSize) - 1;
- var writeBuffer = new byte[sectorSize];
- new Random().NextBytes(writeBuffer);
-
- await _writableRawDisk.WriteSectorsAsync(lastSector, writeBuffer, CancellationToken.None);
-
- // Read from near the end
- var offset = diskSize - sectorSize;
- using var stream = await _writableRawDisk.ReadBytesAsync(offset, sectorSize, CancellationToken.None);
- var readBuffer = new byte[sectorSize];
- var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
-
- Assert.AreEqual(sectorSize, bytesRead, "Should have read a full sector.");
- Assert.AreEqual(writeBuffer, readBuffer, "Data should match what was written.");
- }
-
- [Test]
- public async Task Test_RawDisk_ReadUnalignedOffsetWithMemory_ReturnsCorrectData()
- {
- var sectorSize = _writableRawDisk.SectorSize;
-
- // Write known pattern at sector 1
- var writeBuffer = new byte[sectorSize];
- for (int i = 0; i < sectorSize; i++)
- writeBuffer[i] = (byte)((i * 2) & 0xFF);
-
- await _writableRawDisk.WriteSectorsAsync(1, writeBuffer, CancellationToken.None);
-
- // Read at unaligned offset
- var offset = sectorSize + 4;
- var length = sectorSize - 8;
- var readBuffer = new byte[length];
- var bytesRead = await _writableRawDisk.ReadBytesAsync(offset, readBuffer.AsMemory(), CancellationToken.None);
-
- Assert.AreEqual(length, bytesRead, "Should have read the requested length.");
- for (int i = 0; i < length; i++)
- Assert.AreEqual((byte)(((i + 4) * 2) & 0xFF), readBuffer[i], $"Byte at position {i} should match.");
- }
-
- [Test]
- public async Task Test_RawDisk_ReadUnalignedLengthWithMemory_ReturnsCorrectData()
- {
- var sectorSize = _writableRawDisk.SectorSize;
-
- // Write known pattern at sector 0
- var writeBuffer = new byte[sectorSize];
- for (int i = 0; i < sectorSize; i++)
- writeBuffer[i] = (byte)((i + 100) & 0xFF);
-
- await _writableRawDisk.WriteSectorsAsync(0, writeBuffer, CancellationToken.None);
-
- // Read with unaligned length
- var length = sectorSize - 5;
- var readBuffer = new byte[length];
- var bytesRead = await _writableRawDisk.ReadBytesAsync(0, readBuffer.AsMemory(), CancellationToken.None);
-
- Assert.AreEqual(length, bytesRead, "Should have read the requested length.");
- for (int i = 0; i < length; i++)
- Assert.AreEqual((byte)((i + 100) & 0xFF), readBuffer[i], $"Byte at position {i} should match.");
- }
-
- #endregion
-
- #region IRawDisk Unaligned Write Tests
-
- [Test]
- public async Task Test_RawDisk_WriteUnalignedOffset_DataMatches()
- {
- var sectorSize = _writableRawDisk.SectorSize;
-
- // First, write known patterns to two consecutive sectors
- var sector1Data = new byte[sectorSize];
- var sector2Data = new byte[sectorSize];
- for (int i = 0; i < sectorSize; i++)
- {
- sector1Data[i] = (byte)(i | 0xA0);
- sector2Data[i] = (byte)(i | 0xB0);
- }
-
- await _writableRawDisk.WriteSectorsAsync(1, sector1Data, CancellationToken.None);
- await _writableRawDisk.WriteSectorsAsync(2, sector2Data, CancellationToken.None);
-
- // Now write at unaligned offset (sector_size + 5)
- var unalignedOffset = sectorSize + 5;
- var writeData = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 };
- var bytesWritten = await _writableRawDisk.WriteBytesAsync(unalignedOffset, writeData, CancellationToken.None);
-
- Assert.AreEqual(writeData.Length, bytesWritten, "Should have written all bytes.");
-
- // Read back both sectors and verify only the intended bytes were changed
- using var readStream = await _writableRawDisk.ReadSectorsAsync(1, 2, CancellationToken.None);
- var readBuffer = new byte[sectorSize * 2];
- var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
-
- Assert.AreEqual(sectorSize * 2, bytesRead, "Should have read two sectors.");
-
- // Verify sector 1 data
- for (int i = 0; i < 5; i++)
- Assert.AreEqual((byte)(i | 0xA0), readBuffer[i], $"Byte at position {i} in sector 1 should be unchanged.");
- for (int i = 0; i < writeData.Length; i++)
- Assert.AreEqual(writeData[i], readBuffer[5 + i], $"Written byte at position {5 + i} should match.");
- for (int i = 5 + writeData.Length; i < sectorSize; i++)
- Assert.AreEqual((byte)(i | 0xA0), readBuffer[i], $"Byte at position {i} in sector 1 should be unchanged.");
-
- // Verify sector 2 data (should be completely unchanged)
- for (int i = 0; i < sectorSize; i++)
- Assert.AreEqual((byte)(i | 0xB0), readBuffer[sectorSize + i], $"Byte at position {i} in sector 2 should be unchanged.");
- }
-
- [Test]
- public async Task Test_RawDisk_WriteUnalignedLength_DataMatches()
- {
- var sectorSize = _writableRawDisk.SectorSize;
-
- // Write known pattern to sector 3
- var sectorData = new byte[sectorSize];
- for (int i = 0; i < sectorSize; i++)
- sectorData[i] = (byte)(i | 0xC0);
-
- await _writableRawDisk.WriteSectorsAsync(3, sectorData, CancellationToken.None);
-
- // Write unaligned length (sectorSize - 10)
- var writeLength = sectorSize - 10;
- var writeData = new byte[writeLength];
- for (int i = 0; i < writeLength; i++)
- writeData[i] = (byte)((i + 50) & 0xFF);
-
- var bytesWritten = await _writableRawDisk.WriteBytesAsync(3 * sectorSize, writeData, CancellationToken.None);
-
- Assert.AreEqual(writeData.Length, bytesWritten, "Should have written all bytes.");
-
- // Read back and verify
- using var readStream = await _writableRawDisk.ReadSectorsAsync(3, 1, CancellationToken.None);
- var readBuffer = new byte[sectorSize];
- var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
-
- Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector.");
-
- // First part should be our written data
- for (int i = 0; i < writeLength; i++)
- Assert.AreEqual((byte)((i + 50) & 0xFF), readBuffer[i], $"Byte at position {i} should match written data.");
-
- // Remaining bytes should be unchanged
- for (int i = writeLength; i < sectorSize; i++)
- Assert.AreEqual((byte)(i | 0xC0), readBuffer[i], $"Byte at position {i} should be unchanged.");
- }
-
- [Test]
- public async Task Test_RawDisk_WriteSingleByte_VerifySingleByteChanged()
- {
- var sectorSize = _writableRawDisk.SectorSize;
-
- // Write known pattern to sector 5
- var sectorData = new byte[sectorSize];
- for (int i = 0; i < sectorSize; i++)
- sectorData[i] = (byte)(i & 0xFF);
-
- await _writableRawDisk.WriteSectorsAsync(5, sectorData, CancellationToken.None);
-
- // Write a single byte at offset 5 * sectorSize + 100
- var byteOffset = 5 * sectorSize + 100;
- var singleByte = new byte[] { 0xAB };
- var bytesWritten = await _writableRawDisk.WriteBytesAsync(byteOffset, singleByte, CancellationToken.None);
-
- Assert.AreEqual(1, bytesWritten, "Should have written 1 byte.");
-
- // Read back the full sector
- using var readStream = await _writableRawDisk.ReadSectorsAsync(5, 1, CancellationToken.None);
- var readBuffer = new byte[sectorSize];
- var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
-
- Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector.");
-
- // Verify all bytes except position 100 are unchanged
- for (int i = 0; i < sectorSize; i++)
- if (i == 100)
- Assert.AreEqual(0xAB, readBuffer[i], "Byte at position 100 should be the written value.");
- else
- Assert.AreEqual((byte)(i & 0xFF), readBuffer[i], $"Byte at position {i} should be unchanged.");
- }
-
- [Test]
- public async Task Test_RawDisk_WriteSpanningSectorBoundary_DataMatches()
- {
- var sectorSize = _writableRawDisk.SectorSize;
-
- // Write known patterns to sectors 6 and 7
- var sector6Data = new byte[sectorSize];
- var sector7Data = new byte[sectorSize];
- for (int i = 0; i < sectorSize; i++)
- {
- sector6Data[i] = (byte)(i | 0xD0);
- sector7Data[i] = (byte)(i | 0xE0);
- }
-
- await _writableRawDisk.WriteSectorsAsync(6, sector6Data, CancellationToken.None);
- await _writableRawDisk.WriteSectorsAsync(7, sector7Data, CancellationToken.None);
-
- // Write spanning sector boundary: offset = sector_size - 4, length = 8
- var spanOffset = 6 * sectorSize + sectorSize - 4;
- var spanData = new byte[] { 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88 };
- var bytesWritten = await _writableRawDisk.WriteBytesAsync(spanOffset, spanData, CancellationToken.None);
-
- Assert.AreEqual(spanData.Length, bytesWritten, "Should have written all bytes.");
-
- // Read back both sectors
- using var readStream = await _writableRawDisk.ReadSectorsAsync(6, 2, CancellationToken.None);
- var readBuffer = new byte[sectorSize * 2];
- var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
-
- Assert.AreEqual(sectorSize * 2, bytesRead, "Should have read two sectors.");
-
- // Verify sector 6: last 4 bytes should be the first 4 of our written data
- for (int i = 0; i < sectorSize - 4; i++)
- Assert.AreEqual((byte)(i | 0xD0), readBuffer[i], $"Byte at position {i} in sector 6 should be unchanged.");
- for (int i = 0; i < 4; i++)
- Assert.AreEqual(spanData[i], readBuffer[sectorSize - 4 + i], $"Span byte at position {i} in sector 6 should match.");
-
- // Verify sector 7: first 4 bytes should be the last 4 of our written data
- for (int i = 0; i < 4; i++)
- Assert.AreEqual(spanData[4 + i], readBuffer[sectorSize + i], $"Span byte at position {4 + i} in sector 7 should match.");
- for (int i = 4; i < sectorSize; i++)
- Assert.AreEqual((byte)(i | 0xE0), readBuffer[sectorSize + i], $"Byte at position {i} in sector 7 should be unchanged.");
- }
-
- [Test]
- public async Task Test_RawDisk_WriteAtLastSector_NoOverflow()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var diskSize = _writableRawDisk.Size;
-
- // Calculate the last sector
- var lastSector = (diskSize / sectorSize) - 1;
-
- // Write a full sector at the last valid sector
- var writeData = new byte[sectorSize];
- new Random(42).NextBytes(writeData);
-
- var bytesWritten = await _writableRawDisk.WriteSectorsAsync(lastSector, writeData, CancellationToken.None);
- Assert.AreEqual(sectorSize, bytesWritten, "Should have written a full sector.");
-
- // Read it back and verify
- using var readStream = await _writableRawDisk.ReadSectorsAsync(lastSector, 1, CancellationToken.None);
- var readBuffer = new byte[sectorSize];
- var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
-
- Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector.");
- Assert.AreEqual(writeData, readBuffer, "Data should match.");
- }
-
- [Test]
- public async Task Test_RawDisk_WriteUnalignedWithMemory_DataMatches()
- {
- var sectorSize = _writableRawDisk.SectorSize;
-
- // Write known pattern to sector 8
- var sectorData = new byte[sectorSize];
- for (int i = 0; i < sectorSize; i++)
- sectorData[i] = (byte)(i | 0xF0);
-
- await _writableRawDisk.WriteSectorsAsync(8, sectorData, CancellationToken.None);
-
- // Write at unaligned offset using Memory
- var unalignedOffset = 8 * sectorSize + 10;
- var writeData = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD, 0xEE };
- var bytesWritten = await _writableRawDisk.WriteBytesAsync(unalignedOffset, writeData.AsMemory(), CancellationToken.None);
-
- Assert.AreEqual(writeData.Length, bytesWritten, "Should have written all bytes.");
-
- // Read back and verify
- using var readStream = await _writableRawDisk.ReadSectorsAsync(8, 1, CancellationToken.None);
- var readBuffer = new byte[sectorSize];
- var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
-
- Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector.");
-
- // Verify data before written region
- for (int i = 0; i < 10; i++)
- Assert.AreEqual((byte)(i | 0xF0), readBuffer[i], $"Byte at position {i} should be unchanged.");
-
- // Verify written data
- for (int i = 0; i < writeData.Length; i++)
- Assert.AreEqual(writeData[i], readBuffer[10 + i], $"Written byte at position {10 + i} should match.");
-
- // Verify data after written region
- for (int i = 10 + writeData.Length; i < sectorSize; i++)
- Assert.AreEqual((byte)(i | 0xF0), readBuffer[i], $"Byte at position {i} should be unchanged.");
- }
-
- #endregion
-
- #region PartitionTableFactory detection tests
-
- [Test]
- public async Task Test_PartitionTableFactory_GPTBytes_DetectsGPT()
- {
- var sectorSize = 512;
- var diskBytes = new byte[sectorSize * 4];
-
- // MBR (sector 0) - Protective MBR with GPT signature
- diskBytes[510] = 0x55;
- diskBytes[511] = 0xAA;
- diskBytes[450] = 0xEE;
-
- // GPT header (sector 1) - "EFI PART" signature
- var gptSignature = "EFI PART"u8.ToArray();
- Buffer.BlockCopy(gptSignature, 0, diskBytes, sectorSize, 8);
-
- var partitionTable = await PartitionTableFactory.CreateAsync(diskBytes, sectorSize, CancellationToken.None);
-
- Assert.IsNotNull(partitionTable, "Partition table should be detected.");
- Assert.AreEqual(PartitionTableType.GPT, partitionTable!.TableType, "Should detect GPT partition table.");
- }
-
- [Test]
- public async Task Test_PartitionTableFactory_MBRBytes_DetectsMBR()
- {
- var sectorSize = 512;
- var diskBytes = new byte[sectorSize];
-
- diskBytes[510] = 0x55;
- diskBytes[511] = 0xAA;
- diskBytes[450] = 0x83;
-
- var partitionTable = await PartitionTableFactory.CreateAsync(diskBytes, sectorSize, CancellationToken.None);
-
- Assert.IsNotNull(partitionTable, "Partition table should be detected.");
- Assert.AreEqual(PartitionTableType.MBR, partitionTable!.TableType, "Should detect MBR partition table.");
- }
-
- [Test]
- public async Task Test_PartitionTableFactory_InvalidBytes_ReturnsUnknown()
- {
- var sectorSize = 512;
- var diskBytes = new byte[sectorSize];
-
- var partitionTable = await PartitionTableFactory.CreateAsync(diskBytes, sectorSize, CancellationToken.None);
-
- Assert.IsNotNull(partitionTable, "Partition table should be created even for unknown type.");
- Assert.AreEqual(PartitionTableType.Unknown, partitionTable!.TableType, "Should detect Unknown partition table for invalid bytes.");
- }
-
- [Test]
- public async Task Test_PartitionTableFactory_ProtectiveMBRType_DetectsGPT()
- {
- var sectorSize = 512;
- var diskBytes = new byte[sectorSize * 4];
-
- diskBytes[510] = 0x55;
- diskBytes[511] = 0xAA;
- diskBytes[450] = 0xEE;
-
- var gptSignature = "EFI PART"u8.ToArray();
- Buffer.BlockCopy(gptSignature, 0, diskBytes, sectorSize, 8);
-
- var partitionTable = await PartitionTableFactory.CreateAsync(diskBytes, sectorSize, CancellationToken.None);
-
- Assert.IsNotNull(partitionTable, "Partition table should be detected.");
- Assert.AreEqual(PartitionTableType.GPT, partitionTable!.TableType, "Should detect GPT when protective MBR type (0xEE) is present.");
- }
-
- [Test]
- public async Task Test_PartitionTableFactory_ProtectiveMBRWithoutGptHeader_FallsBackToMBR()
- {
- var sectorSize = 512;
- var diskBytes = new byte[sectorSize * 2];
-
- diskBytes[510] = 0x55;
- diskBytes[511] = 0xAA;
- diskBytes[450] = 0xEE;
-
- var partitionTable = await PartitionTableFactory.CreateAsync(diskBytes, sectorSize, CancellationToken.None);
-
- Assert.IsNotNull(partitionTable, "Partition table should be detected.");
- Assert.AreEqual(PartitionTableType.MBR, partitionTable!.TableType, "Should fall back to MBR when GPT header is invalid.");
- }
-
- [Test]
- public async Task Test_PartitionTableFactory_GPTFromRealDisk_DetectsGPT()
- {
- var sectorSize = s_gptRawDisk!.SectorSize;
- var sectorsToRead = 34;
- using var stream = await s_gptRawDisk.ReadSectorsAsync(0, sectorsToRead, CancellationToken.None);
- var diskBytes = new byte[sectorSize * sectorsToRead];
- await stream.ReadAtLeastAsync(diskBytes, diskBytes.Length, cancellationToken: CancellationToken.None);
-
- var partitionTable = await PartitionTableFactory.CreateAsync(diskBytes, sectorSize, CancellationToken.None);
-
- Assert.IsNotNull(partitionTable, "Partition table should be detected from real disk.");
- Assert.AreEqual(PartitionTableType.GPT, partitionTable!.TableType, "Should detect GPT from real disk bytes.");
- }
-
- #endregion
-
- #region GPT and MBR partition table parsing tests
-
- [Test]
- public async Task Test_GPT_ParseFromRealDisk_ReturnsCorrectPartitions()
- {
- var sectorSize = s_gptRawDisk!.SectorSize;
-
- // Read enough sectors to include protective MBR, GPT header, and partition entries
- var sectorsToRead = 34;
- using var stream = await s_gptRawDisk.ReadSectorsAsync(0, sectorsToRead, CancellationToken.None);
- var diskBytes = new byte[sectorSize * sectorsToRead];
- await stream.ReadAtLeastAsync(diskBytes, diskBytes.Length, cancellationToken: CancellationToken.None);
-
- // Parse the GPT from bytes
- var gpt = new GPT(null);
- var parsed = await gpt.ParseAsync(diskBytes, sectorSize, CancellationToken.None);
-
- Assert.IsTrue(parsed, "GPT should parse successfully from real disk bytes.");
-
- // Verify partition count - we created 2 partitions
- var partitions = new List();
- await foreach (var partition in gpt.EnumeratePartitions(CancellationToken.None))
- {
- partitions.Add(partition);
- }
-
- Assert.AreEqual(2, partitions.Count, "Should have 2 partitions.");
-
- // Verify partition properties
- var firstPartition = partitions[0];
- Assert.IsNotNull(firstPartition, "First partition should exist.");
- Assert.AreEqual(1, firstPartition.PartitionNumber, "Partition number should be 1.");
- Assert.Greater(firstPartition.StartOffset, 0, "StartOffset should be greater than 0.");
- Assert.Greater(firstPartition.Size, 0, "Size should be greater than 0.");
-
- // Verify sector alignment
- Assert.AreEqual(0, firstPartition.StartOffset % sectorSize,
- "StartOffset should be sector-aligned.");
- Assert.AreEqual(0, firstPartition.Size % sectorSize,
- "Size should be sector-aligned.");
-
- gpt.Dispose();
- }
-
- [Test]
- public async Task Test_MBR_ParseFromRealDisk_ReturnsCorrectPartitions()
- {
- var sectorSize = s_mbrRawDisk!.SectorSize;
-
- // Read the MBR sector (first 512 bytes)
- using var stream = await s_mbrRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None);
- var mbrBytes = new byte[sectorSize];
- await stream.ReadAtLeastAsync(mbrBytes, mbrBytes.Length, cancellationToken: CancellationToken.None);
-
- // Parse the MBR from bytes
- var mbr = new MBR(null);
- var parsed = await mbr.ParseAsync(mbrBytes, sectorSize, CancellationToken.None);
-
- Assert.IsTrue(parsed, "MBR should parse successfully from real disk bytes.");
-
- // Verify partition entries - we created 2 partitions
- Assert.AreEqual(2, mbr.NumPartitionEntries, "Should have 2 partition entries.");
-
- // Verify via EnumeratePartitions
- var partitions = new List();
- await foreach (var partition in mbr.EnumeratePartitions(CancellationToken.None))
- {
- partitions.Add(partition);
- }
-
- Assert.AreEqual(2, partitions.Count, "Should enumerate 2 partitions.");
-
- // Verify partition properties
- var firstPartition = partitions[0];
- Assert.IsNotNull(firstPartition, "First partition should exist.");
- Assert.AreEqual(1, firstPartition.PartitionNumber, "Partition number should be 1.");
- Assert.Greater(firstPartition.StartOffset, 0, "StartOffset should be greater than 0.");
- Assert.Greater(firstPartition.Size, 0, "Size should be greater than 0.");
-
- // Verify sector alignment
- Assert.AreEqual(0, firstPartition.StartOffset % sectorSize,
- "StartOffset should be sector-aligned.");
- Assert.AreEqual(0, firstPartition.Size % sectorSize,
- "Size should be sector-aligned.");
-
- mbr.Dispose();
- }
-
- [Test]
- public async Task Test_GPT_EnumeratePartitions_ReturnsCorrectCount()
- {
- var sectorSize = s_gptRawDisk!.SectorSize;
-
- using var stream = await s_gptRawDisk.ReadSectorsAsync(0, 34, CancellationToken.None);
- var diskBytes = new byte[sectorSize * 34];
- await stream.ReadAtLeastAsync(diskBytes, diskBytes.Length, cancellationToken: CancellationToken.None);
-
- var gpt = new GPT(null);
- await gpt.ParseAsync(diskBytes, sectorSize, CancellationToken.None);
-
- var count = 0;
- await foreach (var partition in gpt.EnumeratePartitions(CancellationToken.None))
- {
- count++;
- Assert.IsNotNull(partition, "Partition should not be null.");
- Assert.Greater(partition.PartitionNumber, 0, "Partition number should be positive.");
- }
-
- Assert.AreEqual(2, count, "Should enumerate exactly 2 partitions.");
-
- gpt.Dispose();
- }
-
- [Test]
- public async Task Test_MBR_EnumeratePartitions_ReturnsCorrectCount()
- {
- var sectorSize = s_mbrRawDisk!.SectorSize;
-
- using var stream = await s_mbrRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None);
- var mbrBytes = new byte[sectorSize];
- await stream.ReadAtLeastAsync(mbrBytes, mbrBytes.Length, cancellationToken: CancellationToken.None);
-
- var mbr = new MBR(null);
- await mbr.ParseAsync(mbrBytes, sectorSize, CancellationToken.None);
-
- var count = 0;
- await foreach (var partition in mbr.EnumeratePartitions(CancellationToken.None))
- {
- count++;
- Assert.IsNotNull(partition, "Partition should not be null.");
- Assert.Greater(partition.PartitionNumber, 0, "Partition number should be positive.");
- }
-
- Assert.AreEqual(2, count, "Should enumerate exactly 2 partitions.");
-
- mbr.Dispose();
- }
-
- [Test]
- public async Task Test_GPT_GetPartitionAsync_ReturnsCorrectPartition()
- {
- var sectorSize = s_gptRawDisk!.SectorSize;
-
- using var stream = await s_gptRawDisk.ReadSectorsAsync(0, 34, CancellationToken.None);
- var diskBytes = new byte[sectorSize * 34];
- await stream.ReadAtLeastAsync(diskBytes, diskBytes.Length, cancellationToken: CancellationToken.None);
-
- var gpt = new GPT(null);
- await gpt.ParseAsync(diskBytes, sectorSize, CancellationToken.None);
-
- // Test GetPartitionAsync for partition 1 (should exist)
- var partition1 = await gpt.GetPartitionAsync(1, CancellationToken.None);
- Assert.IsNotNull(partition1, "Partition 1 should exist.");
- Assert.AreEqual(1, partition1!.PartitionNumber, "Partition number should be 1.");
-
- // Test GetPartitionAsync for partition 2 (should exist)
- var partition2 = await gpt.GetPartitionAsync(2, CancellationToken.None);
- Assert.IsNotNull(partition2, "Partition 2 should exist.");
- Assert.AreEqual(2, partition2!.PartitionNumber, "Partition number should be 2.");
-
- // Test GetPartitionAsync for partition 3 (should not exist)
- var partition3 = await gpt.GetPartitionAsync(3, CancellationToken.None);
- Assert.IsNull(partition3, "Partition 3 should not exist.");
-
- // Test GetPartitionAsync for invalid partition number 0
- var partition0 = await gpt.GetPartitionAsync(0, CancellationToken.None);
- Assert.IsNull(partition0, "Partition 0 should not exist (invalid number).");
-
- gpt.Dispose();
- }
-
- [Test]
- public async Task Test_MBR_GetPartitionAsync_ReturnsCorrectPartition()
- {
- var sectorSize = s_mbrRawDisk!.SectorSize;
-
- using var stream = await s_mbrRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None);
- var mbrBytes = new byte[sectorSize];
- await stream.ReadAtLeastAsync(mbrBytes, mbrBytes.Length, cancellationToken: CancellationToken.None);
-
- var mbr = new MBR(null);
- await mbr.ParseAsync(mbrBytes, sectorSize, CancellationToken.None);
-
- // Test GetPartitionAsync for partition 1 (should exist)
- var partition1 = await mbr.GetPartitionAsync(1, CancellationToken.None);
- Assert.IsNotNull(partition1, "Partition 1 should exist.");
- Assert.AreEqual(1, partition1!.PartitionNumber, "Partition number should be 1.");
-
- // Test GetPartitionAsync for partition 2 (should exist)
- var partition2 = await mbr.GetPartitionAsync(2, CancellationToken.None);
- Assert.IsNotNull(partition2, "Partition 2 should exist.");
- Assert.AreEqual(2, partition2!.PartitionNumber, "Partition number should be 2.");
-
- // Test GetPartitionAsync for partition 3 (should not exist)
- var partition3 = await mbr.GetPartitionAsync(3, CancellationToken.None);
- Assert.IsNull(partition3, "Partition 3 should not exist.");
-
- // Test GetPartitionAsync for invalid partition number 0
- var partition0 = await mbr.GetPartitionAsync(0, CancellationToken.None);
- Assert.IsNull(partition0, "Partition 0 should not exist (invalid number).");
-
- mbr.Dispose();
- }
-
- [Test]
- public async Task Test_GPT_PartitionAlignment_IsSectorAligned()
- {
- var sectorSize = s_gptRawDisk!.SectorSize;
-
- using var stream = await s_gptRawDisk.ReadSectorsAsync(0, 34, CancellationToken.None);
- var diskBytes = new byte[sectorSize * 34];
- await stream.ReadAtLeastAsync(diskBytes, diskBytes.Length, cancellationToken: CancellationToken.None);
-
- var gpt = new GPT(null);
- await gpt.ParseAsync(diskBytes, sectorSize, CancellationToken.None);
-
- // Verify all partitions are sector-aligned
- await foreach (var partition in gpt.EnumeratePartitions(CancellationToken.None))
- {
- Assert.AreEqual(0, partition.StartOffset % sectorSize,
- $"Partition {partition.PartitionNumber} StartOffset ({partition.StartOffset}) should be sector-aligned (sector size: {sectorSize}).");
- Assert.AreEqual(0, partition.Size % sectorSize,
- $"Partition {partition.PartitionNumber} Size ({partition.Size}) should be sector-aligned (sector size: {sectorSize}).");
- }
-
- gpt.Dispose();
- }
-
- [Test]
- public async Task Test_MBR_PartitionAlignment_IsSectorAligned()
- {
- var sectorSize = s_mbrRawDisk!.SectorSize;
-
- using var stream = await s_mbrRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None);
- var mbrBytes = new byte[sectorSize];
- await stream.ReadAtLeastAsync(mbrBytes, mbrBytes.Length, cancellationToken: CancellationToken.None);
-
- var mbr = new MBR(null);
- await mbr.ParseAsync(mbrBytes, sectorSize, CancellationToken.None);
-
- // Verify all partitions are sector-aligned
- await foreach (var partition in mbr.EnumeratePartitions(CancellationToken.None))
- {
- Assert.AreEqual(0, partition.StartOffset % sectorSize,
- $"Partition {partition.PartitionNumber} StartOffset ({partition.StartOffset}) should be sector-aligned (sector size: {sectorSize}).");
- Assert.AreEqual(0, partition.Size % sectorSize,
- $"Partition {partition.PartitionNumber} Size ({partition.Size}) should be sector-aligned (sector size: {sectorSize}).");
- }
-
- mbr.Dispose();
- }
-
- #endregion
-
- #region PartitionWriteStream tests
-
- [Test]
- public async Task Test_PartitionWriteStream_WriteAndDispose_DataFlushedToDisk()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionOffset = 10 * sectorSize;
- var partitionSize = 4 * sectorSize;
-
- // Write data using PartitionWriteStream
- var writeData = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 };
- using (var stream = new PartitionWriteStream(_writableRawDisk, partitionOffset, partitionSize))
- await stream.WriteAsync(writeData, CancellationToken.None);
-
- // Read back and verify data was flushed to disk
- using var readStream = await _writableRawDisk.ReadBytesAsync(partitionOffset, writeData.Length, CancellationToken.None);
- var readBuffer = new byte[writeData.Length];
- var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
-
- Assert.AreEqual(writeData.Length, bytesRead, "Should have read the written data.");
- Assert.AreEqual(writeData, readBuffer, "Data should match what was written.");
- }
-
- [Test]
- public void Test_PartitionWriteStream_SeekBegin_ValidPosition()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 4 * sectorSize;
-
- using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize);
-
- // Test Seek with SeekOrigin.Begin
- var newPosition = stream.Seek(100, SeekOrigin.Begin);
- Assert.AreEqual(100, newPosition, "Position should be 100 after seeking from Begin.");
- Assert.AreEqual(100, stream.Position, "Stream.Position should match returned position.");
- }
-
- [Test]
- public void Test_PartitionWriteStream_SeekCurrent_ValidPosition()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 4 * sectorSize;
-
- using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize);
-
- // Write some data first to move position
- stream.Write([0x01, 0x02, 0x03], 0, 3);
- Assert.AreEqual(3, stream.Position, "Position should be 3 after writing 3 bytes.");
-
- // Test Seek with SeekOrigin.Current
- var newPosition = stream.Seek(50, SeekOrigin.Current);
- Assert.AreEqual(53, newPosition, "Position should be 53 after seeking 50 from current position 3.");
- Assert.AreEqual(53, stream.Position, "Stream.Position should match returned position.");
- }
-
- [Test]
- public void Test_PartitionWriteStream_SeekEnd_ValidPosition()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 4 * sectorSize;
-
- using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize);
-
- // Write some data first to establish length
- stream.Write([0x01, 0x02, 0x03, 0x04, 0x05], 0, 5);
- Assert.AreEqual(5, stream.Length, "Length should be 5 after writing 5 bytes.");
-
- // Test Seek with SeekOrigin.End
- var newPosition = stream.Seek(-2, SeekOrigin.End);
- Assert.AreEqual(3, newPosition, "Position should be 3 after seeking -2 from end of length 5.");
- Assert.AreEqual(3, stream.Position, "Stream.Position should match returned position.");
- }
-
- [Test]
- public void Test_PartitionWriteStream_WriteBeyondSize_ThrowsIOException()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionOffset = 10 * sectorSize;
- var partitionSize = 2 * sectorSize;
-
- using var stream = new PartitionWriteStream(_writableRawDisk, partitionOffset, partitionSize);
-
- // Try to write more than partition size
- var largeBuffer = new byte[partitionSize + 100];
- new Random().NextBytes(largeBuffer);
-
- var ex = Assert.Throws(() =>
- {
- stream.Write(largeBuffer, 0, largeBuffer.Length);
- }, "Writing beyond partition size should throw IOException.");
-
- StringAssert.Contains("partition size", ex!.Message, "Exception message should mention partition size.");
- }
-
- [Test]
- public void Test_PartitionWriteStream_SeekBeyondSize_ThrowsIOException()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 2 * sectorSize;
-
- using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize);
-
- // Try to seek beyond partition size
- var ex = Assert.Throws(() =>
- {
- stream.Seek(partitionSize + 100, SeekOrigin.Begin);
- }, "Seeking beyond partition size should throw IOException.");
-
- StringAssert.Contains("partition size", ex!.Message, "Exception message should mention partition size.");
- }
-
- [Test]
- public void Test_PartitionWriteStream_SeekNegativePosition_ThrowsIOException()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 2 * sectorSize;
-
- using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize);
-
- // Try to seek to negative position
- Assert.Throws(() =>
- {
- stream.Seek(-100, SeekOrigin.Begin);
- }, "Seeking to negative position should throw IOException.");
- }
-
- [Test]
- public async Task Test_PartitionWriteStream_WriteAtUnalignedPosition_DataMatches()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionOffset = 15 * sectorSize;
- var partitionSize = 4 * sectorSize;
-
- // Write data at various unaligned positions
- using (var stream = new PartitionWriteStream(_writableRawDisk, partitionOffset, partitionSize))
- {
- // Write at position 0
- stream.Write([0xAA, 0xBB], 0, 2);
-
- // Seek to unaligned position
- stream.Seek(7, SeekOrigin.Begin);
- stream.Write([0xCC, 0xDD, 0xEE], 0, 3);
-
- // Seek to another unaligned position
- stream.Seek(20, SeekOrigin.Begin);
- stream.Write([0x11, 0x22, 0x33, 0x44], 0, 4);
- }
-
- // Read back and verify
- using var readStream = await _writableRawDisk.ReadBytesAsync(partitionOffset, 25, CancellationToken.None);
- var readBuffer = new byte[25];
- var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
-
- Assert.AreEqual(25, bytesRead, "Should read exactly 25 bytes.");
-
- // Verify data at position 0
- Assert.AreEqual(0xAA, readBuffer[0], "Byte at position 0 should be 0xAA.");
- Assert.AreEqual(0xBB, readBuffer[1], "Byte at position 1 should be 0xBB.");
-
- // Verify data at position 7
- Assert.AreEqual(0xCC, readBuffer[7], "Byte at position 7 should be 0xCC.");
- Assert.AreEqual(0xDD, readBuffer[8], "Byte at position 8 should be 0xDD.");
- Assert.AreEqual(0xEE, readBuffer[9], "Byte at position 9 should be 0xEE.");
-
- // Verify data at position 20
- Assert.AreEqual(0x11, readBuffer[20], "Byte at position 20 should be 0x11.");
- Assert.AreEqual(0x22, readBuffer[21], "Byte at position 21 should be 0x22.");
- Assert.AreEqual(0x33, readBuffer[22], "Byte at position 22 should be 0x33.");
- Assert.AreEqual(0x44, readBuffer[23], "Byte at position 23 should be 0x44.");
- }
-
- [Test]
- public async Task Test_PartitionWriteStream_WriteMultipleTimes_DataMatches()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionOffset = 20 * sectorSize;
- var partitionSize = 3 * sectorSize;
-
- // Write data in multiple operations
- using (var stream = new PartitionWriteStream(_writableRawDisk, partitionOffset, partitionSize))
- {
- // First write
- stream.Write([0x01, 0x02, 0x03], 0, 3);
-
- // Second write (should append)
- stream.Write([0x04, 0x05], 0, 2);
-
- // Third write
- stream.Write([0x06, 0x07, 0x08, 0x09], 0, 4);
- }
-
- // Read back and verify all data was written
- using var readStream = await _writableRawDisk.ReadBytesAsync(partitionOffset, 9, CancellationToken.None);
- var readBuffer = new byte[9];
- var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
-
- Assert.AreEqual(9, bytesRead, "Should read exactly 9 bytes.");
-
- var expected = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09 };
- Assert.AreEqual(expected, readBuffer, "All written data should match.");
- }
-
- [Test]
- public void Test_PartitionWriteStream_SetLengthBeyondMaxSize_ThrowsIOException()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 2 * sectorSize;
-
- using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize);
-
- // Try to set length beyond partition size
- var ex = Assert.Throws(() =>
- {
- stream.SetLength(partitionSize + 100);
- }, "Setting length beyond partition size should throw IOException.");
-
- StringAssert.Contains("partition size", ex!.Message, "Exception message should mention partition size.");
- }
-
- [Test]
- public async Task Test_PartitionWriteStream_SetLengthWithinMaxSize_SetsLength()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionOffset = 10 * sectorSize;
- var partitionSize = 2 * sectorSize;
-
- using (var stream = new PartitionWriteStream(_writableRawDisk, partitionOffset, partitionSize))
- {
- // Set length to a specific value within bounds
- stream.SetLength(100);
- Assert.AreEqual(100, stream.Length, "Length should be set to 100.");
-
- // Write some data at position 0
- stream.Write([0xAA, 0xBB, 0xCC], 0, 3);
- // Length should be max(SetLength(100), written position) = 100
- Assert.AreEqual(100, stream.Length, "Length should remain 100 (SetLength value).");
- }
-
- // Verify the 3 bytes were written at position 0 and length 100 bytes are flushed
- using var readStream = await _writableRawDisk.ReadBytesAsync(partitionOffset, 100, CancellationToken.None);
- var readBuffer = new byte[100];
- var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
-
- Assert.AreEqual(100, bytesRead, "Should read exactly 100 bytes.");
-
- Assert.AreEqual(0xAA, readBuffer[0], "Byte at position 0 should be 0xAA.");
- Assert.AreEqual(0xBB, readBuffer[1], "Byte at position 1 should be 0xBB.");
- Assert.AreEqual(0xCC, readBuffer[2], "Byte at position 2 should be 0xCC.");
- }
-
- [Test]
- public void Test_PartitionWriteStream_CanRead_ReturnsFalse()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, sectorSize);
-
- Assert.IsFalse(stream.CanRead, "CanRead should be false.");
- }
-
- [Test]
- public void Test_PartitionWriteStream_CanWrite_ReturnsTrue()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, sectorSize);
-
- Assert.IsTrue(stream.CanWrite, "CanWrite should be true.");
- }
-
- [Test]
- public void Test_PartitionWriteStream_CanSeek_ReturnsTrue()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, sectorSize);
-
- Assert.IsTrue(stream.CanSeek, "CanSeek should be true.");
- }
-
- [Test]
- public void Test_PartitionWriteStream_Read_ThrowsNotSupported()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, sectorSize);
-
- Assert.Throws(() =>
- {
- var _ = stream.Read(new byte[10], 0, 10);
- }, "Read should throw NotSupportedException.");
- }
-
- [Test]
- public async Task Test_PartitionWriteStream_WriteEmptyData_NoFlush()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionOffset = 10 * sectorSize;
- var partitionSize = sectorSize;
-
- // Read initial data at this offset (may not be zero due to previous tests)
- using var initialReadStream = await _writableRawDisk.ReadBytesAsync(partitionOffset, 10, CancellationToken.None);
- var initialBuffer = new byte[10];
- var initialBytesRead = await initialReadStream.ReadAsync(initialBuffer.AsMemory(), CancellationToken.None);
- Assert.AreEqual(10, initialBytesRead, "Should read 10 bytes.");
-
- // Create stream but write nothing
- using (var stream = new PartitionWriteStream(_writableRawDisk, partitionOffset, partitionSize))
- // Don't write anything
- Assert.AreEqual(0, stream.Length, "Length should be 0 when nothing is written.");
-
- // Read back and verify no data was flushed (should be unchanged)
- using var readStream = await _writableRawDisk.ReadBytesAsync(partitionOffset, 10, CancellationToken.None);
- var readBuffer = new byte[10];
- var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
-
- Assert.AreEqual(10, bytesRead, "Should read 10 bytes.");
-
- // All bytes should be unchanged from before the stream was created
- for (int i = 0; i < readBuffer.Length; i++)
- {
- Assert.AreEqual(initialBuffer[i], readBuffer[i], $"Byte at position {i} should be unchanged (no data written).");
- }
- }
-
- #endregion
-
- #region PartitionTableSynthesizer tests
-
- [Test]
- public void Test_PartitionTableSynthesizer_SynthesizeMBR_ContainsValidBootSignature()
- {
- // Create a GeometryMetadata with known partition info
- var metadata = new GeometryMetadata
- {
- Disk = new DiskGeometry
- {
- SectorSize = 512,
- Size = 100 * MiB,
- Sectors = (int)(100 * MiB / 512)
- },
- PartitionTable = new PartitionTableGeometry
- {
- Type = PartitionTableType.MBR,
- SectorSize = 512
- },
- Partitions =
- [
- new PartitionGeometry
- {
- Number = 1,
- Type = PartitionType.Primary,
- FilesystemType = FileSystemType.FAT32,
- StartOffset = 512 * 2048, // Start at sector 2048
- Size = 20 * MiB,
- TableType = PartitionTableType.MBR
- },
- new PartitionGeometry
- {
- Number = 2,
- Type = PartitionType.Primary,
- FilesystemType = FileSystemType.FAT32,
- StartOffset = 512 * 2048 + 20 * MiB,
- Size = 20 * MiB,
- TableType = PartitionTableType.MBR
- }
- ]
- };
-
- // Synthesize MBR
- var mbrData = PartitionTableSynthesizer.SynthesizeMBR(metadata);
-
- Assert.IsNotNull(mbrData, "MBR data should not be null.");
- Assert.GreaterOrEqual(mbrData.Length, 512, "MBR should be at least one sector.");
-
- // Verify boot signature at offset 510-511 (0x55 0xAA)
- Assert.AreEqual(0x55, mbrData[510], "Boot signature first byte should be 0x55.");
- Assert.AreEqual(0xAA, mbrData[511], "Boot signature second byte should be 0xAA.");
- }
-
- [Test]
- public void Test_PartitionTableSynthesizer_SynthesizeGPT_ContainsValidSignature()
- {
- // Create a GeometryMetadata with known partition info
- var metadata = new GeometryMetadata
- {
- Disk = new DiskGeometry
- {
- SectorSize = 512,
- Size = 100 * MiB,
- Sectors = (int)(100 * MiB / 512)
- },
- PartitionTable = new PartitionTableGeometry
- {
- Type = PartitionTableType.GPT,
- SectorSize = 512,
- HasProtectiveMbr = true
- },
- Partitions =
- [
- new PartitionGeometry
- {
- Number = 1,
- Type = PartitionType.Primary,
- FilesystemType = FileSystemType.FAT32,
- StartOffset = 512 * 2048, // Start at sector 2048
- Size = 20 * MiB,
- TableType = PartitionTableType.GPT,
- Name = "Partition 1"
- },
- new PartitionGeometry
- {
- Number = 2,
- Type = PartitionType.Primary,
- FilesystemType = FileSystemType.FAT32,
- StartOffset = 512 * 2048 + 20 * MiB,
- Size = 20 * MiB,
- TableType = PartitionTableType.GPT,
- Name = "Partition 2"
- }
- ]
- };
-
- // Synthesize GPT
- var gptData = PartitionTableSynthesizer.SynthesizeGPT(metadata);
-
- Assert.IsNotNull(gptData, "GPT data should not be null.");
- Assert.Greater(gptData.Length, 512 * 2, "GPT data should include protective MBR and GPT header.");
-
- // Verify protective MBR boot signature at offset 510-511
- Assert.AreEqual(0x55, gptData[510], "Protective MBR boot signature first byte should be 0x55.");
- Assert.AreEqual(0xAA, gptData[511], "Protective MBR boot signature second byte should be 0xAA.");
-
- // Verify GPT signature "EFI PART" at sector 1 (offset 512)
- var expectedSignature = "EFI PART"u8.ToArray();
- for (int i = 0; i < 8; i++)
- Assert.AreEqual(expectedSignature[i], gptData[512 + i], $"GPT signature byte {i} should match.");
- }
-
- [Test]
- public async Task Test_PartitionTableSynthesizer_MBRRoundTrip_PreservesPartitionData()
- {
- // Create a GeometryMetadata with known partition info
- var sectorSize = 512;
- var metadata = new GeometryMetadata
- {
- Disk = new DiskGeometry
- {
- SectorSize = sectorSize,
- Size = 100 * MiB,
- Sectors = (int)(100 * MiB / sectorSize)
- },
- PartitionTable = new PartitionTableGeometry
- {
- Type = PartitionTableType.MBR,
- SectorSize = sectorSize
- },
- Partitions =
- [
- new PartitionGeometry
- {
- Number = 1,
- Type = PartitionType.Primary,
- FilesystemType = FileSystemType.FAT32,
- StartOffset = sectorSize * 2048,
- Size = 20 * MiB,
- TableType = PartitionTableType.MBR
- },
- new PartitionGeometry
- {
- Number = 2,
- Type = PartitionType.Primary,
- FilesystemType = FileSystemType.FAT32,
- StartOffset = sectorSize * 2048 + 20 * MiB,
- Size = 20 * MiB,
- TableType = PartitionTableType.MBR
- }
- ]
- };
-
- // Synthesize MBR
- var mbrData = PartitionTableSynthesizer.SynthesizeMBR(metadata);
-
- // Parse with PartitionTableFactory
- var partitionTable = await PartitionTableFactory.CreateAsync(mbrData, sectorSize, CancellationToken.None);
-
- Assert.IsNotNull(partitionTable, "Partition table should be detected.");
- Assert.AreEqual(PartitionTableType.MBR, partitionTable!.TableType, "Should detect MBR partition table.");
-
- // Verify partition count
- var partitions = new List();
- await foreach (var partition in partitionTable.EnumeratePartitions(CancellationToken.None))
- partitions.Add(partition);
-
- Assert.AreEqual(2, partitions.Count, "Should have 2 partitions.");
-
- // Verify partition properties match original metadata
- for (int i = 0; i < partitions.Count; i++)
- {
- var originalPart = metadata.Partitions![i];
- var parsedPart = partitions[i];
-
- Assert.AreEqual(originalPart.Number, parsedPart.PartitionNumber, $"Partition {i + 1} number should match.");
- Assert.AreEqual(originalPart.StartOffset, parsedPart.StartOffset, $"Partition {i + 1} start offset should match.");
- Assert.AreEqual(originalPart.Size, parsedPart.Size, $"Partition {i + 1} size should match.");
- }
-
- partitionTable.Dispose();
- }
-
- [Test]
- public async Task Test_PartitionTableSynthesizer_GPTRoundTrip_PreservesPartitionData()
- {
- // Create a GeometryMetadata with known partition info
- var sectorSize = 512;
- var metadata = new GeometryMetadata
- {
- Disk = new DiskGeometry
- {
- SectorSize = sectorSize,
- Size = 100 * MiB,
- Sectors = (int)(100 * MiB / sectorSize)
- },
- PartitionTable = new PartitionTableGeometry
- {
- Type = PartitionTableType.GPT,
- SectorSize = sectorSize,
- HasProtectiveMbr = true
- },
- Partitions =
- [
- new PartitionGeometry
- {
- Number = 1,
- Type = PartitionType.Primary,
- FilesystemType = FileSystemType.FAT32,
- StartOffset = sectorSize * 2048,
- Size = 20 * MiB,
- TableType = PartitionTableType.GPT,
- Name = "Test Partition 1"
- },
- new PartitionGeometry
- {
- Number = 2,
- Type = PartitionType.Primary,
- FilesystemType = FileSystemType.FAT32,
- StartOffset = sectorSize * 2048 + 20 * MiB,
- Size = 20 * MiB,
- TableType = PartitionTableType.GPT,
- Name = "Test Partition 2"
- }
- ]
- };
-
- // Synthesize GPT
- var gptData = PartitionTableSynthesizer.SynthesizeGPT(metadata);
-
- // Parse with PartitionTableFactory
- var partitionTable = await PartitionTableFactory.CreateAsync(gptData, sectorSize, CancellationToken.None);
-
- Assert.IsNotNull(partitionTable, "Partition table should be detected.");
- Assert.AreEqual(PartitionTableType.GPT, partitionTable!.TableType, "Should detect GPT partition table.");
-
- // Verify partition count
- var partitions = new List();
- await foreach (var partition in partitionTable.EnumeratePartitions(CancellationToken.None))
- partitions.Add(partition);
-
- Assert.AreEqual(2, partitions.Count, "Should have 2 partitions.");
-
- // Verify partition properties match original metadata
- for (int i = 0; i < partitions.Count; i++)
- {
- var originalPart = metadata.Partitions![i];
- var parsedPart = partitions[i];
-
- Assert.AreEqual(originalPart.Number, parsedPart.PartitionNumber, $"Partition {i + 1} number should match.");
- Assert.AreEqual(originalPart.StartOffset, parsedPart.StartOffset, $"Partition {i + 1} start offset should match.");
- Assert.AreEqual(originalPart.Size, parsedPart.Size, $"Partition {i + 1} size should match.");
- }
-
- partitionTable.Dispose();
- }
-
- [Test]
- public void Test_PartitionTableSynthesizer_SynthesizePartitionTable_MBR_ReturnsMBRData()
- {
- var metadata = new GeometryMetadata
- {
- Disk = new DiskGeometry { SectorSize = 512, Size = 50 * MiB },
- PartitionTable = new PartitionTableGeometry { Type = PartitionTableType.MBR },
- Partitions =
- [
- new PartitionGeometry
- {
- Number = 1,
- Type = PartitionType.Primary,
- StartOffset = 512 * 2048,
- Size = 20 * MiB,
- TableType = PartitionTableType.MBR
- }
- ]
- };
-
- var result = PartitionTableSynthesizer.SynthesizePartitionTable(metadata);
-
- Assert.IsNotNull(result, "Should return MBR data.");
- Assert.AreEqual(0x55, result![510], "Should have valid boot signature.");
- Assert.AreEqual(0xAA, result[511], "Should have valid boot signature.");
- }
-
- [Test]
- public void Test_PartitionTableSynthesizer_SynthesizePartitionTable_GPT_ReturnsGPTData()
- {
- var metadata = new GeometryMetadata
- {
- Disk = new DiskGeometry { SectorSize = 512, Size = 50 * MiB, Sectors = (int)(50 * MiB / 512) },
- PartitionTable = new PartitionTableGeometry { Type = PartitionTableType.GPT },
- Partitions =
- [
- new PartitionGeometry
- {
- Number = 1,
- Type = PartitionType.Primary,
- StartOffset = 512 * 2048,
- Size = 20 * MiB,
- TableType = PartitionTableType.GPT
- }
- ]
- };
-
- var result = PartitionTableSynthesizer.SynthesizePartitionTable(metadata);
-
- Assert.IsNotNull(result, "Should return GPT data.");
- // Check for "EFI PART" signature at sector 1
- var expectedSignature = "EFI PART"u8.ToArray();
- for (int i = 0; i < 8; i++)
- Assert.AreEqual(expectedSignature[i], result![512 + i], $"GPT signature byte {i} should match.");
- }
-
- [Test]
- public void Test_PartitionTableSynthesizer_SynthesizePartitionTable_NullPartitionTable_ReturnsNull()
- {
- var metadata = new GeometryMetadata
- {
- Disk = new DiskGeometry { SectorSize = 512 },
- PartitionTable = null
- };
-
- var result = PartitionTableSynthesizer.SynthesizePartitionTable(metadata);
-
- Assert.IsNull(result, "Should return null when PartitionTable is null.");
- }
-
- [Test]
- public void Test_PartitionTableSynthesizer_SynthesizePartitionTable_UnknownType_ReturnsNull()
- {
- var metadata = new GeometryMetadata
- {
- Disk = new DiskGeometry { SectorSize = 512 },
- PartitionTable = new PartitionTableGeometry { Type = PartitionTableType.Unknown }
- };
-
- var result = PartitionTableSynthesizer.SynthesizePartitionTable(metadata);
-
- Assert.IsNull(result, "Should return null for Unknown partition table type.");
- }
-
- #endregion
-
- #region GeometryMetadata Serialization Tests
-
- ///
- /// Tests that a fully populated GeometryMetadata can be serialized to JSON and
- /// deserialized back, preserving all field values.
- ///
- [Test]
- public void Test_GeometryMetadata_RoundTrip_PreservesAllFields()
- {
- // Create a GeometryMetadata with all fields populated
- var original = new GeometryMetadata
- {
- Version = 1,
- Disk = new DiskGeometry
- {
- DevicePath = "/dev/sda",
- Size = 100 * MiB,
- SectorSize = 512,
- Sectors = (int)(100 * MiB / 512),
- TableType = PartitionTableType.GPT
- },
- PartitionTable = new PartitionTableGeometry
- {
- Type = PartitionTableType.GPT,
- Size = 17408, // Typical GPT size
- SectorSize = 512,
- HasProtectiveMbr = true,
- HeaderSize = 92
- },
- Partitions =
- [
- new PartitionGeometry
- {
- Number = 1,
- Type = PartitionType.Primary,
- StartOffset = 512 * 2048,
- Size = 40 * MiB,
- Name = "Test Partition 1",
- FilesystemType = FileSystemType.FAT32,
- VolumeGuid = Guid.Parse("12345678-1234-1234-1234-123456789abc"),
- TableType = PartitionTableType.GPT
- },
- new PartitionGeometry
- {
- Number = 2,
- Type = PartitionType.Primary,
- StartOffset = 512 * 2048 + 40 * MiB,
- Size = 40 * MiB,
- Name = "Test Partition 2",
- FilesystemType = FileSystemType.NTFS,
- VolumeGuid = Guid.Parse("abcdef12-3456-7890-abcd-ef1234567890"),
- TableType = PartitionTableType.GPT
- }
- ],
- Filesystems =
- [
- new FilesystemGeometry
- {
- PartitionNumber = 1,
- Type = FileSystemType.FAT32,
- PartitionStartOffset = 512 * 2048,
- BlockSize = 4096,
- Metadata = "{\"label\":\"FAT32_VOL\",\"serial\":\"1234-5678\"}"
- },
- new FilesystemGeometry
- {
- PartitionNumber = 2,
- Type = FileSystemType.NTFS,
- PartitionStartOffset = 512 * 2048 + 40 * MiB,
- BlockSize = 4096,
- Metadata = "{\"label\":\"NTFS_VOL\",\"guid\":\"{12345678-1234-1234-1234-123456789ABC}\"}"
- }
- ]
- };
-
- // Serialize to JSON
- var json = original.ToJson();
-
- // Verify JSON is not null or empty
- Assert.IsNotNull(json, "JSON output should not be null.");
- Assert.IsNotEmpty(json, "JSON output should not be empty.");
-
- // Deserialize from JSON
- var deserialized = GeometryMetadata.FromJson(json);
-
- // Verify deserialized object is not null
- Assert.IsNotNull(deserialized, "Deserialized object should not be null.");
-
- // Verify Version
- Assert.AreEqual(original.Version, deserialized!.Version, "Version should match.");
-
- // Verify Disk properties
- Assert.IsNotNull(deserialized.Disk, "Disk should not be null after deserialization.");
- Assert.AreEqual(original.Disk!.DevicePath, deserialized.Disk!.DevicePath, "Disk DevicePath should match.");
- Assert.AreEqual(original.Disk.Size, deserialized.Disk.Size, "Disk Size should match.");
- Assert.AreEqual(original.Disk.SectorSize, deserialized.Disk.SectorSize, "Disk SectorSize should match.");
- Assert.AreEqual(original.Disk.Sectors, deserialized.Disk.Sectors, "Disk Sectors should match.");
- Assert.AreEqual(original.Disk.TableType, deserialized.Disk.TableType, "Disk TableType should match.");
-
- // Verify PartitionTable properties
- Assert.IsNotNull(deserialized.PartitionTable, "PartitionTable should not be null after deserialization.");
- Assert.AreEqual(original.PartitionTable!.Type, deserialized.PartitionTable!.Type, "PartitionTable Type should match.");
- Assert.AreEqual(original.PartitionTable.Size, deserialized.PartitionTable.Size, "PartitionTable Size should match.");
- Assert.AreEqual(original.PartitionTable.SectorSize, deserialized.PartitionTable.SectorSize, "PartitionTable SectorSize should match.");
- Assert.AreEqual(original.PartitionTable.HasProtectiveMbr, deserialized.PartitionTable.HasProtectiveMbr, "PartitionTable HasProtectiveMbr should match.");
- Assert.AreEqual(original.PartitionTable.HeaderSize, deserialized.PartitionTable.HeaderSize, "PartitionTable HeaderSize should match.");
-
- // Verify Partitions
- Assert.IsNotNull(deserialized.Partitions, "Partitions should not be null after deserialization.");
- Assert.AreEqual(original.Partitions!.Count, deserialized.Partitions!.Count, "Partition count should match.");
-
- for (int i = 0; i < original.Partitions.Count; i++)
- {
- var originalPart = original.Partitions[i];
- var deserializedPart = deserialized.Partitions[i];
-
- Assert.AreEqual(originalPart.Number, deserializedPart.Number, $"Partition {i + 1} Number should match.");
- Assert.AreEqual(originalPart.Type, deserializedPart.Type, $"Partition {i + 1} Type should match.");
- Assert.AreEqual(originalPart.StartOffset, deserializedPart.StartOffset, $"Partition {i + 1} StartOffset should match.");
- Assert.AreEqual(originalPart.Size, deserializedPart.Size, $"Partition {i + 1} Size should match.");
- Assert.AreEqual(originalPart.Name, deserializedPart.Name, $"Partition {i + 1} Name should match.");
- Assert.AreEqual(originalPart.FilesystemType, deserializedPart.FilesystemType, $"Partition {i + 1} FilesystemType should match.");
- Assert.AreEqual(originalPart.VolumeGuid, deserializedPart.VolumeGuid, $"Partition {i + 1} VolumeGuid should match.");
- Assert.AreEqual(originalPart.TableType, deserializedPart.TableType, $"Partition {i + 1} TableType should match.");
- }
-
- // Verify Filesystems
- Assert.IsNotNull(deserialized.Filesystems, "Filesystems should not be null after deserialization.");
- Assert.AreEqual(original.Filesystems!.Count, deserialized.Filesystems!.Count, "Filesystem count should match.");
-
- for (int i = 0; i < original.Filesystems.Count; i++)
- {
- var originalFs = original.Filesystems[i];
- var deserializedFs = deserialized.Filesystems[i];
-
- Assert.AreEqual(originalFs.PartitionNumber, deserializedFs.PartitionNumber, $"Filesystem {i + 1} PartitionNumber should match.");
- Assert.AreEqual(originalFs.Type, deserializedFs.Type, $"Filesystem {i + 1} Type should match.");
- Assert.AreEqual(originalFs.PartitionStartOffset, deserializedFs.PartitionStartOffset, $"Filesystem {i + 1} PartitionStartOffset should match.");
- Assert.AreEqual(originalFs.BlockSize, deserializedFs.BlockSize, $"Filesystem {i + 1} BlockSize should match.");
- Assert.AreEqual(originalFs.Metadata, deserializedFs.Metadata, $"Filesystem {i + 1} Metadata should match.");
- }
- }
-
- ///
- /// Tests that a GeometryMetadata with null optional fields can be serialized
- /// and deserialized correctly.
- ///
- [Test]
- public void Test_GeometryMetadata_RoundTrip_NullOptionalFields_PreservesStructure()
- {
- // Create a GeometryMetadata with minimal/null optional fields
- var original = new GeometryMetadata
- {
- Version = 1,
- Disk = new DiskGeometry
- {
- DevicePath = null, // Null optional field
- Size = 50 * MiB,
- SectorSize = 512,
- Sectors = (int)(50 * MiB / 512),
- TableType = PartitionTableType.MBR
- },
- PartitionTable = new PartitionTableGeometry
- {
- Type = PartitionTableType.MBR,
- Size = 512,
- SectorSize = 512,
- HasProtectiveMbr = false,
- HeaderSize = 0,
- MbrSize = 512
- },
- Partitions =
- [
- new PartitionGeometry
- {
- Number = 1,
- Type = PartitionType.Primary,
- StartOffset = 512 * 2048,
- Size = 40 * MiB,
- Name = null, // Null optional field
- FilesystemType = FileSystemType.Unknown,
- VolumeGuid = null, // Null optional field
- TableType = PartitionTableType.MBR
- }
- ],
- Filesystems = null // Null optional collection
- };
-
- // Serialize to JSON
- var json = original.ToJson();
-
- // Verify JSON is not null or empty
- Assert.IsNotNull(json, "JSON output should not be null.");
- Assert.IsNotEmpty(json, "JSON output should not be empty.");
-
- // Deserialize from JSON
- var deserialized = GeometryMetadata.FromJson(json);
-
- // Verify deserialized object is not null
- Assert.IsNotNull(deserialized, "Deserialized object should not be null.");
-
- // Verify null fields remain null
- Assert.IsNull(deserialized!.Disk!.DevicePath, "DevicePath should be null after deserialization.");
- Assert.IsNull(deserialized.Filesystems, "Filesystems should be null after deserialization.");
-
- // Verify partition with null fields
- Assert.IsNotNull(deserialized.Partitions, "Partitions should not be null.");
- Assert.AreEqual(1, deserialized.Partitions!.Count, "Should have 1 partition.");
- Assert.IsNull(deserialized.Partitions[0].Name, "Partition Name should be null.");
- Assert.IsNull(deserialized.Partitions[0].VolumeGuid, "Partition VolumeGuid should be null.");
-
- // Verify other fields are preserved
- Assert.AreEqual(original.Disk.Size, deserialized.Disk.Size, "Disk Size should match.");
- Assert.AreEqual(original.Disk.SectorSize, deserialized.Disk.SectorSize, "Disk SectorSize should match.");
- Assert.AreEqual(original.PartitionTable!.Type, deserialized.PartitionTable!.Type, "PartitionTable Type should match.");
- }
-
- ///
- /// Tests that an empty GeometryMetadata (with all null/empty fields) can be
- /// serialized and deserialized correctly.
- ///
- [Test]
- public void Test_GeometryMetadata_RoundTrip_EmptyMetadata_HandlesGracefully()
- {
- // Create a GeometryMetadata with mostly empty/null fields
- var original = new GeometryMetadata
- {
- Version = 1,
- Disk = null,
- PartitionTable = null,
- Partitions = null,
- Filesystems = null
- };
-
- // Serialize to JSON
- var json = original.ToJson();
-
- // Verify JSON is not null or empty
- Assert.IsNotNull(json, "JSON output should not be null.");
- Assert.IsNotEmpty(json, "JSON output should not be empty.");
-
- // Deserialize from JSON
- var deserialized = GeometryMetadata.FromJson(json);
-
- // Verify deserialized object is not null
- Assert.IsNotNull(deserialized, "Deserialized object should not be null.");
-
- // Verify fields are null
- Assert.IsNull(deserialized!.Disk, "Disk should be null.");
- Assert.IsNull(deserialized.PartitionTable, "PartitionTable should be null.");
- Assert.IsNull(deserialized.Partitions, "Partitions should be null.");
- Assert.IsNull(deserialized.Filesystems, "Filesystems should be null.");
- Assert.AreEqual(1, deserialized.Version, "Version should be preserved.");
- }
-
- ///
- /// Tests that a GeometryMetadata with multiple partitions and filesystems of
- /// different types can be serialized and deserialized correctly.
- ///
- [Test]
- public void Test_GeometryMetadata_RoundTrip_MultiplePartitionsAndFilesystems_PreservesAllData()
- {
- // Create a GeometryMetadata with multiple partitions and filesystems
- var original = new GeometryMetadata
- {
- Version = 1,
- Disk = new DiskGeometry
- {
- DevicePath = "/dev/nvme0n1",
- Size = 500 * MiB,
- SectorSize = 4096, // 4K sectors
- Sectors = (int)(500 * MiB / 4096),
- TableType = PartitionTableType.GPT
- },
- PartitionTable = new PartitionTableGeometry
- {
- Type = PartitionTableType.GPT,
- Size = 32768,
- SectorSize = 4096,
- HasProtectiveMbr = true,
- HeaderSize = 92
- },
- Partitions =
- [
- new PartitionGeometry
- {
- Number = 1,
- Type = PartitionType.Primary,
- StartOffset = 4096 * 6, // Start at sector 6 (after GPT headers)
- Size = 100 * MiB,
- Name = "EFI System Partition",
- FilesystemType = FileSystemType.FAT32,
- VolumeGuid = Guid.NewGuid(),
- TableType = PartitionTableType.GPT
- },
- new PartitionGeometry
- {
- Number = 2,
- Type = PartitionType.Primary,
- StartOffset = 4096 * 6 + 100 * MiB,
- Size = 150 * MiB,
- Name = "Windows OS",
- FilesystemType = FileSystemType.NTFS,
- VolumeGuid = Guid.NewGuid(),
- TableType = PartitionTableType.GPT
- },
- new PartitionGeometry
- {
- Number = 3,
- Type = PartitionType.Primary,
- StartOffset = 4096 * 6 + 250 * MiB,
- Size = 200 * MiB,
- Name = "Linux Root",
- FilesystemType = FileSystemType.Ext4,
- VolumeGuid = Guid.NewGuid(),
- TableType = PartitionTableType.GPT
- }
- ],
- Filesystems =
- [
- new FilesystemGeometry
- {
- PartitionNumber = 1,
- Type = FileSystemType.FAT32,
- PartitionStartOffset = 4096 * 6,
- BlockSize = 4096,
- Metadata = "{\"label\":\"EFI\"}"
- },
- new FilesystemGeometry
- {
- PartitionNumber = 2,
- Type = FileSystemType.NTFS,
- PartitionStartOffset = 4096 * 6 + 100 * MiB,
- BlockSize = 4096,
- Metadata = "{\"label\":\"Windows\"}"
- },
- new FilesystemGeometry
- {
- PartitionNumber = 3,
- Type = FileSystemType.Ext4,
- PartitionStartOffset = 4096 * 6 + 250 * MiB,
- BlockSize = 4096,
- Metadata = "{\"label\":\"/\"}"
- }
- ]
- };
-
- // Serialize to JSON
- var json = original.ToJson();
-
- // Verify JSON is not null or empty
- Assert.IsNotNull(json, "JSON output should not be null.");
- Assert.IsNotEmpty(json, "JSON output should not be empty.");
-
- // Deserialize from JSON
- var deserialized = GeometryMetadata.FromJson(json);
-
- // Verify deserialized object is not null
- Assert.IsNotNull(deserialized, "Deserialized object should not be null.");
-
- // Verify counts
- Assert.AreEqual(3, deserialized!.Partitions!.Count, "Should have 3 partitions.");
- Assert.AreEqual(3, deserialized.Filesystems!.Count, "Should have 3 filesystems.");
-
- // Verify each partition's filesystem type
- Assert.AreEqual(FileSystemType.FAT32, deserialized.Partitions[0].FilesystemType, "Partition 1 should be FAT32.");
- Assert.AreEqual(FileSystemType.NTFS, deserialized.Partitions[1].FilesystemType, "Partition 2 should be NTFS.");
- Assert.AreEqual(FileSystemType.Ext4, deserialized.Partitions[2].FilesystemType, "Partition 3 should be Ext4.");
-
- // Verify each filesystem's type
- Assert.AreEqual(FileSystemType.FAT32, deserialized.Filesystems[0].Type, "Filesystem 1 should be FAT32.");
- Assert.AreEqual(FileSystemType.NTFS, deserialized.Filesystems[1].Type, "Filesystem 2 should be NTFS.");
- Assert.AreEqual(FileSystemType.Ext4, deserialized.Filesystems[2].Type, "Filesystem 3 should be Ext4.");
-
- // Verify 4K sector size is preserved
- Assert.AreEqual(4096, deserialized.Disk!.SectorSize, "Disk SectorSize should be 4096.");
- Assert.AreEqual(4096, deserialized.PartitionTable!.SectorSize, "PartitionTable SectorSize should be 4096.");
- }
-
- ///
- /// Tests that serializing an MBR-based GeometryMetadata produces valid JSON
- /// that can be correctly deserialized.
- ///
- [Test]
- public void Test_GeometryMetadata_RoundTrip_MBRType_PreservesMBRData()
- {
- var original = new GeometryMetadata
- {
- Version = 1,
- Disk = new DiskGeometry
- {
- DevicePath = "/dev/sdb",
- Size = 200 * MiB,
- SectorSize = 512,
- Sectors = (int)(200 * MiB / 512),
- TableType = PartitionTableType.MBR
- },
- PartitionTable = new PartitionTableGeometry
- {
- Type = PartitionTableType.MBR,
- Size = 512,
- SectorSize = 512,
- HasProtectiveMbr = false,
- MbrSize = 512
- },
- Partitions =
- [
- new PartitionGeometry
- {
- Number = 1,
- Type = PartitionType.Primary,
- StartOffset = 512 * 2048,
- Size = 100 * MiB,
- Name = "Primary Partition",
- FilesystemType = FileSystemType.FAT32,
- TableType = PartitionTableType.MBR
- },
- new PartitionGeometry
- {
- Number = 2,
- Type = PartitionType.Primary,
- StartOffset = 512 * 2048 + 100 * MiB,
- Size = 90 * MiB,
- Name = "Secondary Partition",
- FilesystemType = FileSystemType.NTFS,
- TableType = PartitionTableType.MBR
- }
- ],
- Filesystems =
- [
- new FilesystemGeometry
- {
- PartitionNumber = 1,
- Type = FileSystemType.FAT32,
- PartitionStartOffset = 512 * 2048,
- BlockSize = 4096,
- Metadata = null
- }
- ]
- };
-
- // Serialize to JSON
- var json = original.ToJson();
-
- // Deserialize from JSON
- var deserialized = GeometryMetadata.FromJson(json);
-
- // Verify MBR-specific fields
- Assert.IsNotNull(deserialized, "Deserialized object should not be null.");
- Assert.AreEqual(PartitionTableType.MBR, deserialized!.Disk!.TableType, "Disk TableType should be MBR.");
- Assert.AreEqual(PartitionTableType.MBR, deserialized.PartitionTable!.Type, "PartitionTable Type should be MBR.");
- Assert.AreEqual(512, deserialized.PartitionTable.MbrSize, "PartitionTable MbrSize should be 512.");
- Assert.IsFalse(deserialized.PartitionTable.HasProtectiveMbr, "PartitionTable HasProtectiveMbr should be false.");
-
- // Verify all partitions have MBR table type
- foreach (var partition in deserialized.Partitions!)
- {
- Assert.AreEqual(PartitionTableType.MBR, partition.TableType, "All partitions should have MBR TableType.");
- }
- }
-
- ///
- /// Tests that deserializing invalid JSON throws an appropriate exception.
- ///
- [Test]
- public void Test_GeometryMetadata_FromJson_InvalidJson_ThrowsException()
- {
- // Test with invalid JSON - should throw JsonException
- var invalidJson = "{invalid json";
- Assert.Throws(() => GeometryMetadata.FromJson(invalidJson));
- }
-
- ///
- /// Tests that deserializing empty JSON object creates a minimal GeometryMetadata
- /// with default property values set by the class.
- ///
- [Test]
- public void Test_GeometryMetadata_FromJson_EmptyObject_CreatesDefaultInstance()
- {
- var emptyJson = "{}";
- var result = GeometryMetadata.FromJson(emptyJson);
-
- Assert.IsNotNull(result, "Should create an instance from empty JSON object.");
- // Version has a default value of 1 in the class definition
- Assert.AreEqual(1, result!.Version, "Version should have default value (1) from class definition.");
- Assert.IsNull(result.Disk, "Disk should be null.");
- Assert.IsNull(result.PartitionTable, "PartitionTable should be null.");
- Assert.IsNull(result.Partitions, "Partitions should be null.");
- Assert.IsNull(result.Filesystems, "Filesystems should be null.");
- }
-
- #endregion
-
- #region UnknownFilesystem read/write tests
-
- ///
- /// Tests that ListFilesAsync returns the expected block-based files for an UnknownFilesystem.
- ///
- [Test]
- public async Task Test_UnknownFilesystem_ListFilesAsync_ReturnsBlockBasedFiles()
- {
- // Create a partition on the writable disk
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 10 * MiB;
- var partitionOffset = sectorSize * 2048; // Start at sector 2048
-
- // Create a simple partition entry
- var partition = new TestPartition
- {
- StartOffset = partitionOffset,
- Size = partitionSize,
- RawDisk = _writableRawDisk
- };
-
- // Create UnknownFilesystem with 1MB block size
- using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024);
-
- // List files
- var files = new List();
- await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
- files.Add(file);
-
- // Should have 10 files (10MB partition / 1MB block size)
- Assert.AreEqual(10, files.Count, "Should have 10 block files for a 10MB partition with 1MB blocks.");
-
- // Verify each file has correct properties
- for (int i = 0; i < files.Count; i++)
- {
- var file = files[i];
- Assert.IsInstanceOf(file, $"File {i} should be UnknownFilesystemFile.");
- Assert.IsFalse(file.IsDirectory, $"File {i} should not be a directory.");
-
- var unknownFile = (UnknownFilesystemFile)file;
- Assert.AreEqual(i * 1024L * 1024L, unknownFile.Address, $"File {i} should have correct address.");
-
- // All files except possibly the last should have full block size
- if (i < files.Count - 1)
- Assert.AreEqual(1024 * 1024, unknownFile.Size, $"File {i} should have full block size.");
- }
- }
-
- ///
- /// Tests that write and read round-trip preserves data correctly.
- ///
- [Test]
- public async Task Test_UnknownFilesystem_WriteRead_RoundTrip_DataMatches()
- {
- // Create a partition on the writable disk
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 5 * MiB;
- var partitionOffset = sectorSize * 2048;
-
- var partition = new TestPartition
- {
- StartOffset = partitionOffset,
- Size = partitionSize,
- RawDisk = _writableRawDisk
- };
-
- // Create UnknownFilesystem with 1MB block size
- using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024);
-
- // Get first file
- IFile? firstFile = null;
- await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
- {
- firstFile = file;
- break;
- }
- Assert.IsNotNull(firstFile, "Should have at least one file.");
-
- // Write data to the file
- var writeData = new byte[1024 * 1024];
- new Random(42).NextBytes(writeData);
-
- using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None))
- {
- await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None);
- }
-
- // Read data back
- byte[] readData;
- using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None))
- {
- readData = new byte[writeData.Length];
- var bytesRead = await readStream.ReadAsync(readData.AsMemory(), CancellationToken.None);
- Assert.AreEqual(writeData.Length, bytesRead, "Should have read all bytes.");
- }
-
- // Verify data matches
- Assert.AreEqual(writeData, readData, "Read data should match written data.");
- }
-
- ///
- /// Tests that BoundsCheck validates ranges correctly.
- ///
- [Test]
- public void Test_UnknownFilesystem_BoundsCheck_ValidRange_DoesNotThrow()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 5 * MiB;
- var partitionOffset = sectorSize * 2048;
-
- var partition = new TestPartition
- {
- StartOffset = partitionOffset,
- Size = partitionSize,
- RawDisk = _writableRawDisk
- };
-
- using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024);
-
- // Valid ranges should not throw
- Assert.DoesNotThrow(() => fs.BoundsCheck(0, sectorSize), "BoundsCheck should not throw for valid range at start.");
- Assert.DoesNotThrow(() => fs.BoundsCheck(sectorSize, sectorSize), "BoundsCheck should not throw for valid range.");
- Assert.DoesNotThrow(() => fs.BoundsCheck(partitionSize - sectorSize, sectorSize), "BoundsCheck should not throw for valid range at end.");
- }
-
- ///
- /// Tests that BoundsCheck throws for negative start or size.
- ///
- [Test]
- public void Test_UnknownFilesystem_BoundsCheck_NegativeValues_ThrowsArgumentOutOfRange()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 5 * MiB;
- var partitionOffset = sectorSize * 2048;
-
- var partition = new TestPartition
- {
- StartOffset = partitionOffset,
- Size = partitionSize,
- RawDisk = _writableRawDisk
- };
-
- using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024);
-
- // Negative start should throw
- Assert.Throws(() => fs.BoundsCheck(-1, sectorSize), "Negative start should throw.");
-
- // Negative size should throw
- Assert.Throws(() => fs.BoundsCheck(0, -1), "Negative size should throw.");
- }
-
- ///
- /// Tests that BoundsCheck throws for ranges exceeding partition size.
- ///
- [Test]
- public void Test_UnknownFilesystem_BoundsCheck_ExceedsPartitionSize_ThrowsArgumentOutOfRange()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 5 * MiB;
- var partitionOffset = sectorSize * 2048;
-
- var partition = new TestPartition
- {
- StartOffset = partitionOffset,
- Size = partitionSize,
- RawDisk = _writableRawDisk
- };
-
- using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024);
-
- // Range exceeding partition size should throw
- Assert.Throws(() => fs.BoundsCheck(partitionSize - sectorSize, sectorSize * 2), "Range exceeding partition should throw.");
- Assert.Throws(() => fs.BoundsCheck(partitionSize, sectorSize), "Start at partition end should throw.");
- }
-
- ///
- /// Tests that BoundsCheck throws for unaligned start or size.
- ///
- [Test]
- public void Test_UnknownFilesystem_BoundsCheck_UnalignedValues_ThrowsArgumentException()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 5 * MiB;
- var partitionOffset = sectorSize * 2048;
-
- var partition = new TestPartition
- {
- StartOffset = partitionOffset,
- Size = partitionSize,
- RawDisk = _writableRawDisk
- };
-
- using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024);
-
- // Unaligned start should throw
- Assert.Throws(() => fs.BoundsCheck(1, sectorSize), "Unaligned start should throw.");
-
- // Unaligned size should throw
- Assert.Throws(() => fs.BoundsCheck(0, sectorSize + 1), "Unaligned size should throw.");
- }
-
- ///
- /// Tests that UnknownFilesystem constructor throws for unaligned block sizes.
- ///
- [Test]
- public void Test_UnknownFilesystem_Constructor_UnalignedBlockSize_ThrowsArgumentException()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 5 * MiB;
- var partitionOffset = sectorSize * 2048;
-
- var partition = new TestPartition
- {
- StartOffset = partitionOffset,
- Size = partitionSize,
- RawDisk = _writableRawDisk
- };
-
- // Block size not a multiple of sector size should throw
- var unalignedBlockSize = sectorSize + 1;
- var ex = Assert.Throws(() => new UnknownFilesystem(partition, blockSize: unalignedBlockSize),
- "Unaligned block size should throw ArgumentException.");
- Assert.That(ex!.ParamName, Is.EqualTo("blockSize"), "Exception should be for blockSize parameter.");
- }
-
- ///
- /// Tests that UnknownFilesystem constructor accepts valid block sizes.
- ///
- [Test]
- public void Test_UnknownFilesystem_Constructor_ValidBlockSize_CreatesInstance()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 5 * MiB;
- var partitionOffset = sectorSize * 2048;
-
- var partition = new TestPartition
- {
- StartOffset = partitionOffset,
- Size = partitionSize,
- RawDisk = _writableRawDisk
- };
-
- // Valid block sizes should create instance
- Assert.DoesNotThrow(() =>
- {
- using var fs1 = new UnknownFilesystem(partition, blockSize: sectorSize);
- }, "Block size equal to sector size should work.");
-
- Assert.DoesNotThrow(() =>
- {
- using var fs2 = new UnknownFilesystem(partition, blockSize: sectorSize * 4);
- }, "Block size multiple of sector size should work.");
-
- Assert.DoesNotThrow(() =>
- {
- using var fs3 = new UnknownFilesystem(partition, blockSize: 1024 * 1024);
- }, "1MB block size should work.");
- }
-
- ///
- /// Helper class to implement IPartition for testing.
- ///
- private class TestPartition : IPartition
- {
- public long StartOffset { get; init; }
- public long Size { get; init; }
- public IRawDisk? RawDisk { get; init; }
- public int PartitionNumber => 1;
- public string? Name => "TestPartition";
- public Guid? UniqueId => Guid.Empty;
- public PartitionType Type => PartitionType.Primary;
- public FileSystemType FilesystemType => FileSystemType.Unknown;
- public Guid? VolumeGuid => Guid.Empty;
- public IPartitionTable PartitionTable => new TestPartitionTable { RawDisk = RawDisk };
-
- public Task OpenReadAsync(CancellationToken cancellationToken)
- => Task.FromException(new NotImplementedException());
-
- public Task OpenWriteAsync(CancellationToken cancellationToken)
- => Task.FromException(new NotImplementedException());
-
- public void Dispose() { }
- }
-
- ///
- /// Helper class to implement IPartitionTable for testing.
- ///
- private class TestPartitionTable : IPartitionTable
- {
- public IRawDisk? RawDisk { get; init; }
- public PartitionTableType TableType => PartitionTableType.Unknown;
- public long Size => 0;
-
- public IAsyncEnumerable EnumeratePartitions(CancellationToken cancellationToken)
- => AsyncEnumerable.Empty();
-
- public Task GetPartitionAsync(int partitionNumber, CancellationToken cancellationToken)
- => Task.FromResult(null);
-
- public Task GetProtectiveMbrAsync(CancellationToken cancellationToken)
- => Task.FromException(new NotImplementedException());
-
- public Task GetPartitionTableDataAsync(CancellationToken cancellationToken)
- => Task.FromException(new NotImplementedException());
-
- public void Dispose() { }
- }
-
- #endregion
-
- #region UnknownFilesystemStream Unaligned Tests
-
- ///
- /// Tests that writing partial data (less than block size) is correctly flushed on dispose.
- ///
- [Test]
- public async Task Test_UnknownFilesystemStream_WritePartialData_FlushesOnDispose()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 5 * MiB;
- var partitionOffset = sectorSize * 2048;
-
- var partition = new TestPartition
- {
- StartOffset = partitionOffset,
- Size = partitionSize,
- RawDisk = _writableRawDisk
- };
-
- using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024);
-
- // Get first file
- IFile? firstFile = null;
- await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
- {
- firstFile = file;
- break;
- }
- Assert.IsNotNull(firstFile, "Should have at least one file.");
-
- // Write partial data (less than block size)
- var writeData = new byte[1024]; // 1KB, much less than 1MB block
- new Random(42).NextBytes(writeData);
-
- using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None))
- await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None);
-
- // Read back and verify - the written data should be preserved
- byte[] readData;
- using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None))
- {
- readData = new byte[writeData.Length];
- var bytesRead = await readStream.ReadAsync(readData.AsMemory(), CancellationToken.None);
- Assert.AreEqual(writeData.Length, bytesRead, "Should have read all written bytes.");
- }
-
- // Verify data matches
- Assert.AreEqual(writeData, readData, "Partial data should be correctly flushed and readable.");
- }
-
- ///
- /// Tests writing at position 0, seeking to middle, writing more data, and verifying both regions.
- ///
- [Test]
- public async Task Test_UnknownFilesystemStream_WriteSeekWrite_BothRegionsPreserved()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 5 * MiB;
- var partitionOffset = sectorSize * 2048;
-
- var partition = new TestPartition
- {
- StartOffset = partitionOffset,
- Size = partitionSize,
- RawDisk = _writableRawDisk
- };
-
- using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024);
-
- // Get first file
- IFile? firstFile = null;
- await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
- {
- firstFile = file;
- break;
- }
- Assert.IsNotNull(firstFile, "Should have at least one file.");
-
- // Write at position 0, seek to middle, write more data
- var firstWriteData = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 };
- var secondWriteData = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD };
- var middlePosition = 1000;
-
- using (var writeStream = await fs.OpenReadWriteStreamAsync(firstFile!, CancellationToken.None))
- {
- // Write at position 0
- await writeStream.WriteAsync(firstWriteData.AsMemory(), CancellationToken.None);
-
- // Seek to middle position
- writeStream.Seek(middlePosition, SeekOrigin.Begin);
-
- // Write more data
- await writeStream.WriteAsync(secondWriteData.AsMemory(), CancellationToken.None);
- }
-
- // Read back and verify both regions
- byte[] readData;
- using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None))
- {
- readData = new byte[middlePosition + secondWriteData.Length];
- var bytesRead = await readStream.ReadAsync(readData.AsMemory(), CancellationToken.None);
- Assert.AreEqual(middlePosition + secondWriteData.Length, bytesRead, "Should have read all data.");
- }
-
- // Verify first region (position 0-4)
- for (int i = 0; i < firstWriteData.Length; i++)
- Assert.AreEqual(firstWriteData[i], readData[i], $"Byte at position {i} should match first write.");
-
- // Verify middle region (position 1000-1003)
- for (int i = 0; i < secondWriteData.Length; i++)
- Assert.AreEqual(secondWriteData[i], readData[middlePosition + i], $"Byte at position {middlePosition + i} should match second write.");
- }
-
- ///
- /// Tests reading after seeking to a non-zero position.
- ///
- [Test]
- public async Task Test_UnknownFilesystemStream_ReadAfterSeek_ReturnsCorrectData()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 5 * MiB;
- var partitionOffset = sectorSize * 2048;
-
- var partition = new TestPartition
- {
- StartOffset = partitionOffset,
- Size = partitionSize,
- RawDisk = _writableRawDisk
- };
-
- using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024);
-
- // Get first file
- IFile? firstFile = null;
- await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
- {
- firstFile = file;
- break;
- }
- Assert.IsNotNull(firstFile, "Should have at least one file.");
-
- // Write test data
- var writeData = new byte[4096]; // 4KB of test data
- for (int i = 0; i < writeData.Length; i++)
- writeData[i] = (byte)(i & 0xFF);
-
- using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None))
- await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None);
-
- // Read after seeking to non-zero position
- using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None))
- {
- // Seek to position 100
- readStream.Seek(100, SeekOrigin.Begin);
-
- // Read remaining data
- var readBuffer = new byte[writeData.Length - 100];
- var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
- Assert.AreEqual(writeData.Length - 100, bytesRead, "Should have read remaining bytes after seek.");
-
- // Verify data matches expected values
- for (int i = 0; i < bytesRead; i++)
- {
- Assert.AreEqual((byte)((i + 100) & 0xFF), readBuffer[i], $"Byte at position {100 + i} should match.");
- }
- }
- }
-
- ///
- /// Tests that writing data that doesn't fill the entire stream preserves the written data
- /// while the remaining bytes retain their previous values.
- ///
- [Test]
- public async Task Test_UnknownFilesystemStream_WritePartial_PreservesData()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 5 * MiB;
- var partitionOffset = sectorSize * 2048;
-
- var partition = new TestPartition
- {
- StartOffset = partitionOffset,
- Size = partitionSize,
- RawDisk = _writableRawDisk
- };
-
- // Use a small block size for this test
- var blockSize = 4096;
- using var fs = new UnknownFilesystem(partition, blockSize: blockSize);
-
- // Get first file
- IFile? firstFile = null;
- await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
- {
- firstFile = file;
- break;
- }
- Assert.IsNotNull(firstFile, "Should have at least one file.");
-
- // First, initialize the entire block with zeros by writing zeros
- var zeroData = new byte[blockSize];
- using (var zeroStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None))
- await zeroStream.WriteAsync(zeroData.AsMemory(), CancellationToken.None);
-
- // Now write partial data at the beginning
- var writeData = new byte[] { 0x11, 0x22, 0x33, 0x44, 0x55 };
-
- using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None))
- await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None);
-
- // Read the entire block and verify written data and remaining zeros
- using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None);
- var readBuffer = new byte[blockSize];
- var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
- Assert.AreEqual(blockSize, bytesRead, "Should have read the full block.");
-
- // Verify written data
- for (int i = 0; i < writeData.Length; i++)
- Assert.AreEqual(writeData[i], readBuffer[i], $"Byte at position {i} should match written data.");
-
- // Verify remaining bytes are still zero
- for (int i = writeData.Length; i < blockSize; i++)
- Assert.AreEqual(0, readBuffer[i], $"Byte at position {i} should be zero.");
- }
-
- ///
- /// Tests the lazy-load behavior: first read triggers disk read, subsequent reads use buffer.
- ///
- [Test]
- public async Task Test_UnknownFilesystemStream_LazyLoad_FirstReadTriggersDiskRead()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 5 * MiB;
- var partitionOffset = sectorSize * 2048;
-
- var partition = new TestPartition
- {
- StartOffset = partitionOffset,
- Size = partitionSize,
- RawDisk = _writableRawDisk
- };
-
- using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024);
-
- // Get first file
- IFile? firstFile = null;
- await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
- {
- firstFile = file;
- break;
- }
- Assert.IsNotNull(firstFile, "Should have at least one file.");
-
- // Write test data first
- var writeData = new byte[2048];
- new Random(123).NextBytes(writeData);
-
- using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None))
- await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None);
-
- // Now test lazy loading - first read should trigger disk read
- using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None);
-
- // First small read (triggers lazy load)
- var firstReadBuffer = new byte[512];
- var bytesRead1 = await readStream.ReadAsync(firstReadBuffer.AsMemory(), CancellationToken.None);
- Assert.AreEqual(512, bytesRead1, "First read should return 512 bytes.");
-
- // Second read should use cached buffer (no disk read)
- var secondReadBuffer = new byte[512];
- var bytesRead2 = await readStream.ReadAsync(secondReadBuffer.AsMemory(), CancellationToken.None);
- Assert.AreEqual(512, bytesRead2, "Second read should return 512 bytes from buffer.");
-
- // Verify both reads returned correct data
- for (int i = 0; i < 512; i++)
- {
- Assert.AreEqual(writeData[i], firstReadBuffer[i], $"First read: byte at position {i} should match.");
- Assert.AreEqual(writeData[i + 512], secondReadBuffer[i], $"Second read: byte at position {i + 512} should match.");
- }
-
- // Seek back and read again - should still use buffer
- readStream.Seek(0, SeekOrigin.Begin);
- var thirdReadBuffer = new byte[512];
- var bytesRead3 = await readStream.ReadAsync(thirdReadBuffer.AsMemory(), CancellationToken.None);
- Assert.AreEqual(512, bytesRead3, "Third read after seek should return 512 bytes.");
-
- for (int i = 0; i < 512; i++)
- Assert.AreEqual(writeData[i], thirdReadBuffer[i], $"Third read: byte at position {i} should match after seek.");
- }
-
- ///
- /// Tests seeking with SeekOrigin.Current works correctly.
- ///
- [Test]
- public async Task Test_UnknownFilesystemStream_SeekCurrent_UpdatesPosition()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 5 * MiB;
- var partitionOffset = sectorSize * 2048;
-
- var partition = new TestPartition
- {
- StartOffset = partitionOffset,
- Size = partitionSize,
- RawDisk = _writableRawDisk
- };
-
- using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024);
-
- // Get first file
- IFile? firstFile = null;
- await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
- {
- firstFile = file;
- break;
- }
- Assert.IsNotNull(firstFile, "Should have at least one file.");
-
- // Write test data
- var writeData = new byte[4096];
- for (int i = 0; i < writeData.Length; i++)
- writeData[i] = (byte)(i & 0xFF);
-
- using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None))
- await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None);
-
- // Test Seek with SeekOrigin.Current
- using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None))
- {
- // Read first 100 bytes
- var buffer1 = new byte[100];
- var bytesRead1 = await readStream.ReadAsync(buffer1.AsMemory(), CancellationToken.None);
- Assert.AreEqual(100, bytesRead1, "First read should return 100 bytes.");
- Assert.AreEqual(100, readStream.Position, "Position should be 100 after first read.");
-
- // Seek forward 100 bytes from current
- var newPos = readStream.Seek(100, SeekOrigin.Current);
- Assert.AreEqual(200, newPos, "Position should be 200 after Seek(Current, 100).");
- Assert.AreEqual(200, readStream.Position, "Stream.Position should match returned position.");
-
- // Read next 100 bytes
- var buffer2 = new byte[100];
- var bytesRead2 = await readStream.ReadAsync(buffer2.AsMemory(), CancellationToken.None);
- Assert.AreEqual(100, bytesRead2, "Second read should return 100 bytes.");
-
- // Verify data at position 200
- for (int i = 0; i < 100; i++)
- Assert.AreEqual((byte)((200 + i) & 0xFF), buffer2[i], $"Byte at position {200 + i} should match.");
- }
- }
-
- ///
- /// Tests seeking with SeekOrigin.End works correctly.
- ///
- [Test]
- public async Task Test_UnknownFilesystemStream_SeekEnd_UpdatesPosition()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 5 * MiB;
- var partitionOffset = sectorSize * 2048;
-
- var partition = new TestPartition
- {
- StartOffset = partitionOffset,
- Size = partitionSize,
- RawDisk = _writableRawDisk
- };
-
- var blockSize = 4096;
- using var fs = new UnknownFilesystem(partition, blockSize: blockSize);
-
- // Get first file
- IFile? firstFile = null;
- await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
- {
- firstFile = file;
- break;
- }
- Assert.IsNotNull(firstFile, "Should have at least one file.");
-
- // Write test data
- var writeData = new byte[blockSize];
- for (int i = 0; i < writeData.Length; i++)
- writeData[i] = (byte)(i & 0xFF);
-
- using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None))
- await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None);
-
- // Test Seek with SeekOrigin.End
- using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None);
-
- // Seek to 100 bytes from end
- var newPos = readStream.Seek(-100, SeekOrigin.End);
- Assert.AreEqual(blockSize - 100, newPos, "Position should be blockSize - 100.");
- Assert.AreEqual(blockSize - 100, readStream.Position, "Stream.Position should match.");
-
- // Read remaining bytes
- var buffer = new byte[100];
- var bytesRead = await readStream.ReadAsync(buffer.AsMemory(), CancellationToken.None);
- Assert.AreEqual(100, bytesRead, "Should read 100 bytes.");
-
- // Verify last 100 bytes
- for (int i = 0; i < 100; i++)
- Assert.AreEqual((byte)((blockSize - 100 + i) & 0xFF), buffer[i], $"Byte at position {blockSize - 100 + i} should match.");
- }
-
- ///
- /// Tests that reading past the end of the stream returns 0 bytes.
- ///
- [Test]
- public async Task Test_UnknownFilesystemStream_ReadPastEnd_ReturnsZero()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 5 * MiB;
- var partitionOffset = sectorSize * 2048;
-
- var partition = new TestPartition
- {
- StartOffset = partitionOffset,
- Size = partitionSize,
- RawDisk = _writableRawDisk
- };
-
- var blockSize = 4096;
- using var fs = new UnknownFilesystem(partition, blockSize: blockSize);
-
- // Get first file
- IFile? firstFile = null;
- await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
- {
- firstFile = file;
- break;
- }
- Assert.IsNotNull(firstFile, "Should have at least one file.");
-
- // Write small amount of test data
- var writeData = new byte[100];
- new Random(42).NextBytes(writeData);
-
- using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None))
- await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None);
-
- // Test reading past end
- using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None);
-
- // Seek to near end
- readStream.Seek(blockSize - 10, SeekOrigin.Begin);
-
- // Try to read 100 bytes when only 10 remain
- var buffer = new byte[100];
- var bytesRead = await readStream.ReadAsync(buffer.AsMemory(), CancellationToken.None);
- Assert.AreEqual(10, bytesRead, "Should only read remaining 10 bytes.");
-
- // Try to read again - should return 0
- bytesRead = await readStream.ReadAsync(buffer.AsMemory(), CancellationToken.None);
- Assert.AreEqual(0, bytesRead, "Should return 0 when at end of stream.");
- }
-
- ///
- /// Tests that unaligned writes within the stream work correctly.
- ///
- [Test]
- public async Task Test_UnknownFilesystemStream_UnalignedWrite_DataMatches()
- {
- var sectorSize = _writableRawDisk.SectorSize;
- var partitionSize = 5 * MiB;
- var partitionOffset = sectorSize * 2048;
-
- var partition = new TestPartition
- {
- StartOffset = partitionOffset,
- Size = partitionSize,
- RawDisk = _writableRawDisk
- };
-
- var blockSize = 8192;
- using var fs = new UnknownFilesystem(partition, blockSize: blockSize);
-
- // Get first file
- IFile? firstFile = null;
- await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
- {
- firstFile = file;
- break;
- }
- Assert.IsNotNull(firstFile, "Should have at least one file.");
-
- // First, zero-fill the block so gap bytes are deterministic
- using (var zeroStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None))
- await zeroStream.WriteAsync(new byte[blockSize].AsMemory(), CancellationToken.None);
-
- // Write at unaligned positions using read-write stream (pre-loads zeroed data)
- using (var writeStream = await fs.OpenReadWriteStreamAsync(firstFile!, CancellationToken.None))
- {
- // Write 3 bytes at position 0
- await writeStream.WriteAsync(new byte[] { 0xAA, 0xBB, 0xCC }.AsMemory(), CancellationToken.None);
-
- // Seek to unaligned position 5
- writeStream.Seek(5, SeekOrigin.Begin);
- await writeStream.WriteAsync(new byte[] { 0xDD, 0xEE }.AsMemory(), CancellationToken.None);
-
- // Seek to another unaligned position 10
- writeStream.Seek(10, SeekOrigin.Begin);
- await writeStream.WriteAsync(new byte[] { 0x11, 0x22, 0x33, 0x44 }.AsMemory(), CancellationToken.None);
- }
-
- // Read back and verify
- using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None))
- {
- var readBuffer = new byte[14];
- var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
- Assert.AreEqual(14, bytesRead, "Should read 14 bytes.");
-
- // Verify written data
- Assert.AreEqual(0xAA, readBuffer[0], "Byte 0 should be 0xAA");
- Assert.AreEqual(0xBB, readBuffer[1], "Byte 1 should be 0xBB");
- Assert.AreEqual(0xCC, readBuffer[2], "Byte 2 should be 0xCC");
- // Bytes 3-4 should be zero
- Assert.AreEqual(0, readBuffer[3], "Byte 3 should be 0");
- Assert.AreEqual(0, readBuffer[4], "Byte 4 should be 0");
- Assert.AreEqual(0xDD, readBuffer[5], "Byte 5 should be 0xDD");
- Assert.AreEqual(0xEE, readBuffer[6], "Byte 6 should be 0xEE");
- // Bytes 7-9 should be zero
- Assert.AreEqual(0, readBuffer[7], "Byte 7 should be 0");
- Assert.AreEqual(0, readBuffer[8], "Byte 8 should be 0");
- Assert.AreEqual(0, readBuffer[9], "Byte 9 should be 0");
- Assert.AreEqual(0x11, readBuffer[10], "Byte 10 should be 0x11");
- Assert.AreEqual(0x22, readBuffer[11], "Byte 11 should be 0x22");
- Assert.AreEqual(0x33, readBuffer[12], "Byte 12 should be 0x33");
- Assert.AreEqual(0x44, readBuffer[13], "Byte 13 should be 0x44");
- }
- }
-
- #endregion
-
- #region 12. PooledBuffer and PooledMemoryStream Tests
-
- ///
- /// Tests that PooledBuffer rents a buffer from the ArrayPool and returns it on dispose.
- ///
- [Test]
- public void Test_PooledBuffer_RentAndReturn_BufferRecycled()
- {
- // Rent a buffer using PooledBuffer
- using var pooledBuffer = new PooledBuffer(1024);
- byte[] bufferArray = pooledBuffer.Array;
- var bufferLength = pooledBuffer.Length;
-
- // Verify the buffer was rented
- Assert.IsNotNull(bufferArray, "Buffer array should not be null.");
- Assert.AreEqual(1024, bufferLength, "Buffer length should match requested size.");
- Assert.GreaterOrEqual(bufferArray.Length, 1024, "Underlying array should be at least the requested size.");
-
- // Write some data to verify we have a valid buffer
- bufferArray[0] = 0xAB;
- bufferArray[1023] = 0xCD;
-
- // Verify Memory and Span properties work correctly
- var memory = pooledBuffer.Memory;
- var span = pooledBuffer.Span;
-
- Assert.AreEqual(1024, memory.Length, "Memory length should match requested size.");
- Assert.AreEqual(1024, span.Length, "Span length should match requested size.");
- Assert.AreEqual(0xAB, memory.Span[0], "Memory should reflect buffer changes.");
- Assert.AreEqual(0xCD, span[1023], "Span should reflect buffer changes.");
- }
-
- ///
- /// Tests that PooledBuffer handles different sizes correctly.
- ///
- [Test]
- public void Test_PooledBuffer_DifferentSizes_WorksCorrectly()
- {
- // Test small buffer
- using (var small = new PooledBuffer(16))
- {
- Assert.AreEqual(16, small.Length, "Small buffer length should be correct.");
- Assert.GreaterOrEqual(small.Array.Length, 16, "Small buffer array should be at least requested size.");
- }
-
- // Test large buffer (above LOH threshold of ~85KB)
- using (var large = new PooledBuffer(128 * 1024))
- {
- Assert.AreEqual(128 * 1024, large.Length, "Large buffer length should be correct.");
- Assert.GreaterOrEqual(large.Array.Length, 128 * 1024, "Large buffer array should be at least requested size.");
- }
-
- // Test zero-size buffer
- using (var empty = new PooledBuffer(0))
- {
- Assert.AreEqual(0, empty.Length, "Empty buffer length should be 0.");
- Assert.IsNotNull(empty.Array, "Empty buffer array should still be rented.");
- }
- }
-
- ///
- /// Tests that PooledMemoryStream reads correctly and returns buffer on dispose.
- ///
- [Test]
- public void Test_PooledMemoryStream_Read_ReturnsCorrectData()
- {
- // Rent a buffer and populate it with test data
- byte[] buffer;
- using (var pooledBuffer = new PooledBuffer(1024))
- {
- buffer = pooledBuffer.Array;
- for (int i = 0; i < 1024; i++)
- buffer[i] = (byte)(i & 0xFF);
- }
-
- // Create a PooledMemoryStream with the buffer
- using var stream = new PooledMemoryStream(buffer, 1024);
-
- // Verify stream properties
- Assert.IsTrue(stream.CanRead, "Stream should be readable.");
- Assert.IsTrue(stream.CanSeek, "Stream should be seekable.");
- Assert.IsFalse(stream.CanWrite, "Stream should not be writable.");
- Assert.AreEqual(1024, stream.Length, "Stream length should be correct.");
- Assert.AreEqual(0, stream.Position, "Initial position should be 0.");
-
- // Read data from the stream
- var readBuffer = new byte[256];
- var bytesRead = stream.Read(readBuffer, 0, 256);
-
- Assert.AreEqual(256, bytesRead, "Should have read 256 bytes.");
- Assert.AreEqual(256, stream.Position, "Position should be updated.");
-
- // Verify the data
- for (int i = 0; i < 256; i++)
- Assert.AreEqual((byte)(i & 0xFF), readBuffer[i], $"Byte at position {i} should match.");
- }
-
- ///
- /// Tests that PooledMemoryStream seek operations work correctly.
- ///
- [Test]
- public void Test_PooledMemoryStream_Seek_BeginCurrentEnd_WorksCorrectly()
- {
- // Rent a buffer and populate it
- byte[] buffer;
- using (var pooledBuffer = new PooledBuffer(512))
- {
- buffer = pooledBuffer.Array;
- for (int i = 0; i < 512; i++)
- buffer[i] = (byte)(i & 0xFF);
- }
-
- using (var stream = new PooledMemoryStream(buffer, 512))
- {
- // Test SeekOrigin.Begin
- var pos = stream.Seek(100, SeekOrigin.Begin);
- Assert.AreEqual(100, pos, "Seek from Begin should return correct position.");
- Assert.AreEqual(100, stream.Position, "Position should be 100.");
-
- // Read a byte to verify position
- var readBuffer = new byte[1];
- var bytesRead = stream.Read(readBuffer, 0, 1);
- Assert.AreEqual(1, bytesRead, "Should read 1 byte.");
- Assert.AreEqual(100, readBuffer[0], "Should read byte at position 100.");
-
- // Test SeekOrigin.Current
- pos = stream.Seek(50, SeekOrigin.Current);
- Assert.AreEqual(151, pos, "Seek from Current should return correct position.");
- Assert.AreEqual(151, stream.Position, "Position should be 151.");
-
- bytesRead = stream.Read(readBuffer, 0, 1);
- Assert.AreEqual(1, bytesRead, "Should read 1 byte.");
- Assert.AreEqual(151, readBuffer[0], "Should read byte at position 151.");
-
- // Test SeekOrigin.End
- pos = stream.Seek(-10, SeekOrigin.End);
- Assert.AreEqual(502, pos, "Seek from End should return correct position.");
- Assert.AreEqual(502, stream.Position, "Position should be 502.");
-
- bytesRead = stream.Read(readBuffer, 0, 1);
- Assert.AreEqual(1, bytesRead, "Should read 1 byte.");
- // 502 & 0xFF = 246 (byte overflow)
- Assert.AreEqual(246, readBuffer[0], "Should read byte at position 502 (502 & 0xFF = 246).");
- }
- }
-
- ///
- /// Tests that seeking beyond stream bounds throws ArgumentOutOfRangeException.
- ///
- [Test]
- public void Test_PooledMemoryStream_SeekOutOfBounds_ThrowsArgumentOutOfRange()
- {
- byte[] buffer;
- using (var pooledBuffer = new PooledBuffer(100))
- buffer = pooledBuffer.Array;
-
- using var stream = new PooledMemoryStream(buffer, 100);
-
- // Seek before beginning
- Assert.Throws(() => stream.Seek(-1, SeekOrigin.Begin), "Seek before beginning should throw.");
-
- // Seek past end
- Assert.Throws(() => stream.Seek(101, SeekOrigin.Begin), "Seek past end should throw.");
-
- // Seek before beginning using Current
- stream.Seek(50, SeekOrigin.Begin);
- Assert.Throws(() => stream.Seek(-51, SeekOrigin.Current), "Seek before beginning from current should throw.");
-
- // Seek past end using End
- Assert.Throws(() => stream.Seek(1, SeekOrigin.End), "Seek past end from end should throw.");
- }
-
- ///
- /// Tests that reading past the end of PooledMemoryStream returns 0 bytes.
- ///
- [Test]
- public void Test_PooledMemoryStream_ReadPastEnd_ReturnsZero()
- {
- byte[] buffer;
- using (var pooledBuffer = new PooledBuffer(100))
- {
- buffer = pooledBuffer.Array;
- // Fill with test data
- for (int i = 0; i < 100; i++)
- buffer[i] = (byte)(i & 0xFF);
- }
-
- using var stream = new PooledMemoryStream(buffer, 100);
-
- // Seek to near end
- stream.Seek(95, SeekOrigin.Begin);
-
- // Read 10 bytes when only 5 remain
- var readBuffer = new byte[10];
- var bytesRead = stream.Read(readBuffer, 0, 10);
-
- Assert.AreEqual(5, bytesRead, "Should only read remaining 5 bytes.");
- Assert.AreEqual(100, stream.Position, "Position should be at end.");
-
- // Read again - should return 0
- bytesRead = stream.Read(readBuffer, 0, 10);
- Assert.AreEqual(0, bytesRead, "Should return 0 when at end of stream.");
- }
-
- ///
- /// Tests that PooledMemoryStream returns buffer to pool on dispose.
- ///
- [Test]
- public void Test_PooledMemoryStream_Dispose_ReturnsBufferToPool()
- {
- // This test verifies that the stream can be disposed without errors
- // and that the buffer is returned to the pool (which we can't directly verify,
- // but we can ensure disposal works correctly)
-
- byte[] buffer;
- using (var pooledBuffer = new PooledBuffer(256))
- buffer = pooledBuffer.Array;
-
- var stream = new PooledMemoryStream(buffer, 256);
-
- // Use the stream
- var readBuffer = new byte[64];
- var bytesRead = stream.Read(readBuffer, 0, 64);
- Assert.AreEqual(64, bytesRead, "Should read 64 bytes.");
-
- // Dispose
- stream.Dispose();
-
- // After dispose, attempting to read should throw ObjectDisposedException
- Assert.Throws(() => { var _ = stream.Read(readBuffer, 0, 64); }, "Reading after dispose should throw.");
-
- // Note: Seek doesn't throw ObjectDisposedException in this implementation
- // because it doesn't access the buffer directly (only _position and _length)
-
- // CanRead, CanSeek should return false after dispose
- Assert.IsFalse(stream.CanRead, "CanRead should be false after dispose.");
- Assert.IsFalse(stream.CanSeek, "CanSeek should be false after dispose.");
- }
-
- ///
- /// Tests that PooledMemoryStream write and set length operations throw NotSupportedException.
- ///
- [Test]
- public void Test_PooledMemoryStream_WriteAndSetLength_ThrowsNotSupported()
- {
- byte[] buffer;
- using (var pooledBuffer = new PooledBuffer(100))
- buffer = pooledBuffer.Array;
-
- using var stream = new PooledMemoryStream(buffer, 100);
-
- // Write should throw
- Assert.Throws(() => stream.Write(new byte[10], 0, 10), "Write should throw NotSupportedException.");
-
- // SetLength should throw
- Assert.Throws(() => stream.SetLength(50), "SetLength should throw NotSupportedException.");
- }
-
- ///
- /// Tests that PooledMemoryStream handles empty buffers correctly.
- ///
- [Test]
- public void Test_PooledMemoryStream_EmptyBuffer_WorksCorrectly()
- {
- byte[] buffer;
- using (var pooledBuffer = new PooledBuffer(0))
- buffer = pooledBuffer.Array;
-
- using var stream = new PooledMemoryStream(buffer, 0);
-
- Assert.AreEqual(0, stream.Length, "Length should be 0.");
- Assert.AreEqual(0, stream.Position, "Position should be 0.");
-
- // Read should return 0
- var readBuffer = new byte[10];
- var bytesRead = stream.Read(readBuffer, 0, 10);
- Assert.AreEqual(0, bytesRead, "Read from empty stream should return 0.");
-
- // Seek to 0 should work
- var pos = stream.Seek(0, SeekOrigin.Begin);
- Assert.AreEqual(0, pos, "Seek to 0 should work.");
- }
-
- ///
- /// Tests that PooledBuffer can be used multiple times in succession.
- ///
- [Test]
- public void Test_PooledBuffer_MultipleInstances_WorkIndependently()
- {
- // Create multiple PooledBuffers
- using var buffer1 = new PooledBuffer(64);
- using var buffer2 = new PooledBuffer(128);
- using var buffer3 = new PooledBuffer(256);
-
- // Write different data to each
- buffer1.Array[0] = 0x11;
- buffer2.Array[0] = 0x22;
- buffer3.Array[0] = 0x33;
-
- // Verify each buffer has its own data
- Assert.AreEqual(0x11, buffer1.Array[0], "Buffer1 should have its own data.");
- Assert.AreEqual(0x22, buffer2.Array[0], "Buffer2 should have its own data.");
- Assert.AreEqual(0x33, buffer3.Array[0], "Buffer3 should have its own data.");
-
- // Verify lengths
- Assert.AreEqual(64, buffer1.Length, "Buffer1 length should be correct.");
- Assert.AreEqual(128, buffer2.Length, "Buffer2 length should be correct.");
- Assert.AreEqual(256, buffer3.Length, "Buffer3 length should be correct.");
- }
-
- #endregion
-
- #region RestoreProvider Path Parsing Tests
-
- ///
- /// Creates a RestoreProvider with mock partitions and filesystems for testing path parsing.
- /// Uses reflection to set up the internal state.
- ///
- private static RestoreProvider CreateRestoreProviderForPathParsingTests()
- {
- // Create a RestoreProvider using the default constructor (for metadata loading)
- var provider = new RestoreProvider();
-
- // Create mock partition tables
- var gptTable = new MockPartitionTable(PartitionTableType.GPT);
- var mbrTable = new MockPartitionTable(PartitionTableType.MBR);
-
- // Create mock partitions
- var partitions = new List
- {
- new MockPartition(gptTable, 1, PartitionType.Primary, 1048576, 20971520, "EFI System", FileSystemType.FAT32),
- new MockPartition(gptTable, 2, PartitionType.Primary, 22020096, 41943040, "Windows OS", FileSystemType.NTFS),
- new MockPartition(mbrTable, 1, PartitionType.Primary, 1048576, 10485760, "MBR Partition 1", FileSystemType.FAT32),
- new MockPartition(mbrTable, 2, PartitionType.Primary, 11534336, 20971520, "MBR Partition 2", FileSystemType.NTFS)
- };
-
- // Create mock filesystems
- var filesystems = new List
- {
- new MockFilesystem(partitions[0], FileSystemType.FAT32),
- new MockFilesystem(partitions[1], FileSystemType.NTFS),
- new MockFilesystem(partitions[2], FileSystemType.FAT32),
- new MockFilesystem(partitions[3], FileSystemType.NTFS)
- };
-
- // Use reflection to set the private fields
- var partitionsField = typeof(RestoreProvider).GetField("_partitions", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
- var filesystemsField = typeof(RestoreProvider).GetField("_filesystems", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
-
- partitionsField?.SetValue(provider, partitions);
- filesystemsField?.SetValue(provider, filesystems);
-
- return provider;
- }
-
- ///
- /// Mock implementation of IPartitionTable for testing.
- ///
- private class MockPartitionTable(PartitionTableType tableType) : IPartitionTable
- {
- public IRawDisk? RawDisk => null;
- public PartitionTableType TableType { get; } = tableType;
-
- public IAsyncEnumerable EnumeratePartitions(CancellationToken cancellationToken)
- {
- return AsyncEnumerable.Empty();
- }
-
- public Task GetPartitionAsync(int partitionNumber, CancellationToken cancellationToken)
- {
- return Task.FromResult(null);
- }
-
- public Task GetProtectiveMbrAsync(CancellationToken cancellationToken)
- {
- return Task.FromResult(new MemoryStream());
- }
-
- public Task GetPartitionTableDataAsync(CancellationToken cancellationToken)
- {
- return Task.FromResult(new MemoryStream());
- }
-
- public void Dispose() { }
- }
-
- ///
- /// Mock implementation of IPartition for testing.
- ///
- private class MockPartition(IPartitionTable table, int number, PartitionType type, long startOffset, long size, string? name, FileSystemType fsType) : IPartition
- {
- public IPartitionTable PartitionTable { get; } = table;
- public int PartitionNumber { get; } = number;
- public PartitionType Type { get; } = type;
- public long StartOffset { get; } = startOffset;
- public long Size { get; } = size;
- public string? Name { get; } = name;
- public FileSystemType FilesystemType { get; } = fsType;
- public Guid? VolumeGuid { get; } = Guid.NewGuid();
-
- public Task OpenReadAsync(CancellationToken cancellationToken)
- {
- return Task.FromResult(new MemoryStream());
- }
-
- public Task OpenWriteAsync(CancellationToken cancellationToken)
- {
- return Task.FromResult(new MemoryStream());
- }
-
- public void Dispose() { }
- }
-
- ///
- /// Mock implementation of IFilesystem for testing.
- ///
- private class MockFilesystem(IPartition partition, FileSystemType type) : IFilesystem
- {
- public IPartition Partition { get; } = partition;
- public FileSystemType Type { get; } = type;
-
- public Task