Merge branch 'master' into performance/backup-arraypool
This commit is contained in:
@@ -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; }
|
||||
|
||||
/// <summary>
|
||||
/// Results from remote synchronization operations to multiple destinations.
|
||||
/// </summary>
|
||||
IRemoteSynchronizationResults[] RemoteSynchronizationResults { get; }
|
||||
}
|
||||
|
||||
public interface IRestoreResults : IBasicResults
|
||||
@@ -380,6 +385,47 @@ namespace Duplicati.Library.Interface
|
||||
bool Dryrun { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Results from a remote synchronization operation to a single destination.
|
||||
/// </summary>
|
||||
public interface IRemoteSynchronizationResults : IBasicResults
|
||||
{
|
||||
/// <summary>
|
||||
/// The destination URL or identifier.
|
||||
/// </summary>
|
||||
string Destination { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of files deleted from the destination.
|
||||
/// </summary>
|
||||
long DeletedFileCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of files renamed at the destination (retention mode).
|
||||
/// </summary>
|
||||
long RenamedFileCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of files copied to the destination.
|
||||
/// </summary>
|
||||
long CopiedFileCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of files verified at the destination.
|
||||
/// </summary>
|
||||
long VerifiedFileCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of files that failed verification.
|
||||
/// </summary>
|
||||
long FailedVerificationCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Total size of files copied in bytes.
|
||||
/// </summary>
|
||||
long CopiedFileSize { get; }
|
||||
}
|
||||
|
||||
public interface ICreateLogDatabaseResults : IBasicResults
|
||||
{
|
||||
string TargetPath { get; }
|
||||
@@ -463,7 +509,7 @@ namespace Duplicati.Library.Interface
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The status of a change in a test entry
|
||||
/// The status of a change in a test entry
|
||||
/// </summary>
|
||||
public enum TestEntryStatus
|
||||
{
|
||||
|
||||
@@ -26,7 +26,9 @@ namespace Duplicati.Library.Main.Backend
|
||||
/// <param name="retryDelay">The delay between retries, in milliseconds.</param>
|
||||
/// <param name="autoCreateFolders">Whether to automatically create folders if they do not exist.</param>
|
||||
/// <param name="retryWithExponentialBackoff">Whether to use exponential backoff for retries.</param>
|
||||
public class LightWeightBackendManager(string backendUrl, Dictionary<string, string> options, int maxRetries = 3, int retryDelay = 1000, bool autoCreateFolders = false, bool retryWithExponentialBackoff = false) : IDisposable
|
||||
/// <param name="progressUpdater">An optional progress updater for file operations.</param>
|
||||
/// <param name="backendProgressUpdater">An optional progress updater for backend operations.</param>
|
||||
internal class LightWeightBackendManager(string backendUrl, Dictionary<string, string> 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<LightWeightBackendManager>();
|
||||
|
||||
@@ -115,6 +117,46 @@ namespace Duplicati.Library.Main.Backend
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="remotename">The name of the remote file to get.</param>
|
||||
/// <param name="token">A cancellation token to cancel the operation.</param>
|
||||
/// <returns>A task representing the asynchronous get operation. The task result is the path to the temporary file containing the downloaded data.</returns>
|
||||
public async Task<TempFile> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts a file to the remote backend from a temporary file.
|
||||
/// This method retries the operation with a delay if it fails.
|
||||
/// </summary>
|
||||
/// <param name="remotename">The name of the remote file to put.</param>
|
||||
/// <param name="temp">The temporary file containing the data to upload.</param>
|
||||
/// <param name="token">A cancellation token to cancel the operation.</param>
|
||||
/// <returns>A task representing the asynchronous put operation.</returns>
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the state of the backend manager.
|
||||
/// This method resets the download and upload flags and resets the retry delay to its initial value.
|
||||
/// </summary>
|
||||
/// <returns>A task representing the asynchronous reset operation.</returns>
|
||||
public async Task Reset()
|
||||
{
|
||||
_anyDownloaded = false;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CoCoL" Version="1.8.4" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
||||
<PackageReference Include="System.CommandLine.NamingConventionBinder" Version="2.0.0-beta4.22272.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Global configuration for the tool. Should be set after parsing the commandline arguments.
|
||||
/// </summary>
|
||||
public sealed record RemoteSynchronizationConfig
|
||||
(
|
||||
// Arguments
|
||||
string Src,
|
||||
string Dst,
|
||||
|
||||
// Options
|
||||
bool AutoCreateFolders,
|
||||
int BackendRetries,
|
||||
int BackendRetryDelay,
|
||||
bool BackendRetryWithExponentialBackoff,
|
||||
bool Confirm,
|
||||
bool DryRun,
|
||||
List<string> DstOptions,
|
||||
bool Force,
|
||||
List<string> GlobalOptions,
|
||||
string LogFile,
|
||||
string LogLevel,
|
||||
bool ParseArgumentsOnly,
|
||||
bool Progress,
|
||||
bool Retention,
|
||||
int Retry,
|
||||
List<string> 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}";
|
||||
}
|
||||
};
|
||||
+910
@@ -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
|
||||
|
||||
/// <summary>
|
||||
/// Remote synchronization tool.
|
||||
/// </summary>
|
||||
public static class RemoteSynchronizationRunner
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// The log tag for this tool.
|
||||
/// </summary>
|
||||
private static readonly string LOGTAG = Duplicati.Library.Logging.Log.LogTagFromType(typeof(RemoteSynchronizationRunner));
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for running the tool logic with parsed arguments.
|
||||
/// </summary>
|
||||
/// <param name="args">The commandline arguments</param>
|
||||
/// <returns>0 on success, -1 on abort, and the number of errors encountered otherwise.</returns>
|
||||
public static async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
var arg_src = new Argument<string>(name: "backend_src", description: Strings.SourceBackendDescription);
|
||||
var arg_dst = new Argument<string>(name: "backend_dst", description: Strings.DestinationBackendDescription);
|
||||
|
||||
var root_cmd = new RootCommand(Strings.RootCommandDescription)
|
||||
{
|
||||
arg_src,
|
||||
arg_dst,
|
||||
|
||||
new Option<bool>(aliases: ["--auto-create-folders"], description: Strings.AutoCreateFoldersDescription, getDefaultValue: () => true),
|
||||
new Option<int>(aliases: ["--backend-retries"], description: Strings.BackendRetriesDescription, getDefaultValue: () => 3) { Arity = ArgumentArity.ExactlyOne },
|
||||
new Option<int>(aliases: ["--backend-retry-delay"], description: Strings.BackendRetryDelayDescription, getDefaultValue: () => 1000) { Arity = ArgumentArity.ExactlyOne },
|
||||
new Option<bool>(aliases: ["--backend-retry-with-exponential-backoff"], description: Strings.BackendRetryWithExponentialBackoffDescription, getDefaultValue: () => true),
|
||||
new Option<bool>(aliases: ["--confirm", "--yes", "-y"], description: Strings.ConfirmDescription, getDefaultValue: () => false),
|
||||
new Option<bool>(aliases: ["--dry-run", "-d"], description: Strings.DryRunDescription, getDefaultValue: () => false),
|
||||
OptionWithMultipleTokens(aliases: ["--dst-options"], description: Strings.DstOptionsDescription, getDefaultValue: () => []),
|
||||
new Option<bool>(aliases: ["--force", "-f"], description: Strings.ForceDescription, getDefaultValue: () => false),
|
||||
OptionWithMultipleTokens(aliases: ["--global-options"], description: Strings.GlobalOptionsDescription, getDefaultValue: () => []),
|
||||
new Option<string>(aliases: ["--log-file"], description: Strings.LogFileDescription, getDefaultValue: () => "") { Arity = ArgumentArity.ExactlyOne },
|
||||
new Option<string>(aliases: ["--log-level"], description: Strings.LogLevelDescription, getDefaultValue: () => "Information") { Arity = ArgumentArity.ExactlyOne },
|
||||
new Option<bool>(aliases: ["--parse-arguments-only"], description: Strings.ParseArgumentsOnlyDescription, getDefaultValue: () => false),
|
||||
new Option<bool>(aliases: ["--progress"], description: Strings.ProgressDescription, getDefaultValue: () => false),
|
||||
new Option<bool>(aliases: ["--retention"], description: Strings.RetentionDescription, getDefaultValue: () => false),
|
||||
new Option<int>(aliases: ["--retry"], description: Strings.RetryDescription, getDefaultValue: () => 3) { Arity = ArgumentArity.ExactlyOne },
|
||||
OptionWithMultipleTokens(aliases: ["--src-options"], description: Strings.SrcOptionsDescription, getDefaultValue: () => []),
|
||||
new Option<bool>(aliases: ["--verify-contents"], description: Strings.VerifyContentsDescription, getDefaultValue: () => false),
|
||||
new Option<bool>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The main logic of the tool.
|
||||
/// </summary>
|
||||
/// <param name="config">The parsed configuration for the tool.</param>
|
||||
/// <param name="token">The cancellation token to use for the asynchronous operations.</param>
|
||||
/// <param name="progressUpdater">Optional progress updater for reporting file count and transfer progress to the UI.</param>
|
||||
/// <param name="backendProgressUpdater">Optional backend progress updater for reporting transfer speed to the UI.</param>
|
||||
/// <returns>The return code (0 on success).</returns>
|
||||
internal static async Task<int> Run(RemoteSynchronizationConfig config, CancellationToken token, IOperationProgressUpdater? progressUpdater = null, IBackendProgressUpdater? backendProgressUpdater = null, IBasicResults? results = null)
|
||||
{
|
||||
// Parse the log level
|
||||
var log_level_parsed = Enum.TryParse<Duplicati.Library.Logging.LogMessageType>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The core logic of the tool, without logging setup.
|
||||
/// </summary>
|
||||
/// <param name="config">The parsed configuration for the tool.</param>
|
||||
/// <param name="token">The cancellation token to use for the asynchronous operations.</param>
|
||||
/// <param name="progressUpdater">Optional progress updater for reporting file count and transfer progress to the UI.</param>
|
||||
/// <param name="backendProgressUpdater">Optional backend progress updater for reporting transfer speed to the UI.</param>
|
||||
/// <returns>The return code (0 on success).</returns>
|
||||
private static async Task<int> 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
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="b_src">The source backend.</param>
|
||||
/// <param name="b_dst">The destination backend.</param>
|
||||
/// <param name="files">The files that will be copied.</param>
|
||||
/// <param name="config">The parsed configuration for the tool.</param>
|
||||
/// <param name="filesProcessedOffset">The number of files already processed before this copy phase (e.g., from delete/rename), used for progress reporting.</param>
|
||||
/// <param name="totalFiles">The total number of files to be processed in the entire operation (delete + copy), used for progress reporting.</param>
|
||||
/// <param name="token">The cancellation token to use for the asynchronous operations.</param>
|
||||
/// <param name="progressUpdater">Optional progress updater for reporting file-level progress to the UI.</param>
|
||||
/// <param name="backendProgressUpdater">Optional backend progress updater for reporting transfer speed to the UI.</param>
|
||||
/// <returns>A tuple holding the number of succesful copies and a List of the files that failed.</returns>
|
||||
private static async Task<(long, IEnumerable<IFileEntry>)> CopyAsync(LightWeightBackendManager b_src, LightWeightBackendManager b_dst, IEnumerable<IFileEntry> files, RemoteSynchronizationConfig config, long filesProcessedOffset, long totalFiles, CancellationToken token, IOperationProgressUpdater? progressUpdater = null, IBackendProgressUpdater? backendProgressUpdater = null)
|
||||
{
|
||||
long successful_copies = 0;
|
||||
long sizeProcessed = 0;
|
||||
List<IFileEntry> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the files from a backend.
|
||||
/// </summary>
|
||||
/// <param name="b">The backend to delete the files from.</param>
|
||||
/// <param name="files">The files to delete.</param>
|
||||
/// <param name="config">The parsed configuration for the tool.</param>
|
||||
/// <param name="token">The cancellation token to use for the asynchronous operations.</param>
|
||||
/// <param name="filesProcessedOffset">The number of files already processed before this delete phase, used for overall progress reporting.</param>
|
||||
/// <param name="totalFiles">The total number of files to be processed in the entire operation (delete + copy), used for overall progress reporting.</param>
|
||||
/// <param name="progressUpdater">Optional progress updater for reporting file-level progress to the UI.</param>
|
||||
/// <param name="backendProgressUpdater">Optional backend progress updater for reporting delete speed to the UI.</param>
|
||||
/// <returns>The number of successful deletions.</returns>
|
||||
private static async Task<long> DeleteAsync(LightWeightBackendManager b, IEnumerable<IFileEntry> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an option that allows multiple tokens and multiple arguments per token.
|
||||
/// </summary>
|
||||
/// <param name="aliases">The aliases for the option.</param>
|
||||
/// <param name="description">The description for the option.</param>
|
||||
/// <returns>The created option.</returns>
|
||||
private static Option<List<string>> OptionWithMultipleTokens(string[] aliases, string description, Func<List<string>> getDefaultValue)
|
||||
{
|
||||
return new Option<List<string>>(aliases: aliases, description: description, getDefaultValue: getDefaultValue)
|
||||
{
|
||||
Arity = ArgumentArity.OneOrMore,
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A log destination that writes to multiple destinations.
|
||||
/// </summary>
|
||||
private sealed class MultiLogDestination : ILogDestination, IDisposable
|
||||
{
|
||||
private readonly List<ILogDestination> m_destinations;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the MultiLogDestination class.
|
||||
/// </summary>
|
||||
/// <param name="destinations">The log destinations to write to.</param>
|
||||
public MultiLogDestination(IEnumerable<ILogDestination?> destinations)
|
||||
{
|
||||
m_destinations = destinations.Where(x => x != null).Cast<ILogDestination>().ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a log entry to all destinations.
|
||||
/// </summary>
|
||||
/// <param name="entry">The log entry to write.</param>
|
||||
public void WriteMessage(LogEntry entry)
|
||||
{
|
||||
foreach (var destination in m_destinations)
|
||||
destination.WriteMessage(entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes of the resources used by this instance.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var destination in m_destinations)
|
||||
if (destination is IDisposable disposable)
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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\"".
|
||||
/// </summary>
|
||||
/// <param name="options">The list of string options to parse</param>
|
||||
/// <returns>A dictionary with the parsed options, where the key is the option name and the value is the option value.</returns>
|
||||
/// <exception cref="ArgumentException">If an option was not parsed correctly.</exception>
|
||||
private static Dictionary<string, string> ParseOptions(IEnumerable<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="b_src">The source lightweight backend manager.</param>
|
||||
/// <param name="b_dst">The destination lightweight backend manager.</param>
|
||||
/// <param name="config">The parsed configuration for the tool.</param>
|
||||
/// <param name="token">The cancellation token to use for the asynchronous operations.</param>
|
||||
/// <returns>A tuple of Lists each holding the files to copy, delete and verify.</returns>
|
||||
private static async Task<(IEnumerable<IFileEntry>, IEnumerable<IFileEntry>, IEnumerable<IFileEntry>)> PrepareFileLists(LightWeightBackendManager b_src, LightWeightBackendManager b_dst, RemoteSynchronizationConfig config, CancellationToken token)
|
||||
{
|
||||
IEnumerable<IFileEntry> 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<string, IFileEntry> 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<IFileEntry>();
|
||||
var to_delete = new HashSet<string>();
|
||||
var to_verify = new List<IFileEntry>();
|
||||
|
||||
// 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<IFileEntry> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renames the files in a backend.
|
||||
/// The renaming is done by deleting the file and re-uploading it with a new name.
|
||||
/// </summary>
|
||||
/// <param name="bm">The lightweight backend manager to issue rename operations to.</param>
|
||||
/// <param name="files">The files to rename.</param>
|
||||
/// <param name="config">The parsed configuration for the tool.</param>
|
||||
/// <param name="token">The cancellation token to use for the asynchronous operations.</param>
|
||||
/// <param name="filesProcessedOffset">The number of files already processed before this rename phase, used for overall progress reporting.</param>
|
||||
/// <param name="totalFiles">The total number of files to be processed in the entire operation (delete + copy), used for progress reporting.</param>
|
||||
/// <param name="progressUpdater">Optional progress updater for reporting file-level progress to the UI.</param>
|
||||
/// <param name="backendProgressUpdater">Optional backend progress updater for reporting transfer speed to the UI.</param>
|
||||
/// <returns>The number of successful renames.</returns>
|
||||
private static async Task<long> RenameAsync(LightWeightBackendManager bm, IEnumerable<IFileEntry> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="b_src">The source lightweight backend manager.</param>
|
||||
/// <param name="b_dst">The destination lightweight backend manager.</param>
|
||||
/// <param name="files">The files to verify.</param>
|
||||
/// <param name="config">The parsed configuration for the tool.</param>
|
||||
/// <param name="token">The cancellation token to use for the asynchronous operations.</param>
|
||||
/// <returns>A list of the files that failed verification.</returns>
|
||||
private static async Task<IEnumerable<IFileEntry>> VerifyAsync(LightWeightBackendManager b_src, LightWeightBackendManager b_dst, IEnumerable<IFileEntry> files, RemoteSynchronizationConfig config, CancellationToken token)
|
||||
{
|
||||
var errors = new List<IFileEntry>();
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Localized strings for the Remote Synchronization tool.
|
||||
/// </summary>
|
||||
public static class Strings
|
||||
{
|
||||
/// <summary>
|
||||
/// Root command description.
|
||||
/// </summary>
|
||||
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).
|
||||
"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Description of the handler
|
||||
/// </summary>
|
||||
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)"); } }
|
||||
|
||||
/// <summary>
|
||||
/// The source backend string argument description.
|
||||
/// </summary>
|
||||
public static string SourceBackendDescription { get { return LC.L("The source backend string"); } }
|
||||
|
||||
/// <summary>
|
||||
/// The destination backend string argument description.
|
||||
/// </summary>
|
||||
public static string DestinationBackendDescription { get { return LC.L("The destination backend string"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Auto-create folders option description.
|
||||
/// </summary>
|
||||
public static string AutoCreateFoldersDescription { get { return LC.L("Automatically create folders in the destination backend if they do not exist"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Backend retries option description.
|
||||
/// </summary>
|
||||
public static string BackendRetriesDescription { get { return LC.L("Number of times to recreate a backend on backend errors"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Backend retry delay option description.
|
||||
/// </summary>
|
||||
public static string BackendRetryDelayDescription { get { return LC.L("Delay in milliseconds between backend retries"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Backend retry with exponential backoff option description.
|
||||
/// </summary>
|
||||
public static string BackendRetryWithExponentialBackoffDescription { get { return LC.L("Use exponential backoff for backend retries, multiplying the delay by two for each failure."); } }
|
||||
|
||||
/// <summary>
|
||||
/// Confirm option description.
|
||||
/// </summary>
|
||||
public static string ConfirmDescription { get { return LC.L("Automatically confirm the operation"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Dry-run option description.
|
||||
/// </summary>
|
||||
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"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Destination options option description.
|
||||
/// </summary>
|
||||
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]"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Force option description.
|
||||
/// </summary>
|
||||
public static string ForceDescription { get { return LC.L("Force the synchronization"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Global options option description.
|
||||
/// </summary>
|
||||
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]"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Log file option description.
|
||||
/// </summary>
|
||||
public static string LogFileDescription { get { return LC.L("The log file to write to. If not set here, global options will be checked [default: \"\"]"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Log level option description.
|
||||
/// </summary>
|
||||
public static string LogLevelDescription { get { return LC.L("The log level to use. If not set here, global options will be checked"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Parse arguments only option description.
|
||||
/// </summary>
|
||||
public static string ParseArgumentsOnlyDescription { get { return LC.L("Only parse the arguments and then exit"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Progress option description.
|
||||
/// </summary>
|
||||
public static string ProgressDescription { get { return LC.L("Print progress to STDOUT"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Retention option description.
|
||||
/// </summary>
|
||||
public static string RetentionDescription { get { return LC.L("Toggles whether to keep old files. Any deletes will be renames instead"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Retry option description.
|
||||
/// </summary>
|
||||
public static string RetryDescription { get { return LC.L("Number of times to retry on errors"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Source options option description.
|
||||
/// </summary>
|
||||
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]"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Verify contents option description.
|
||||
/// </summary>
|
||||
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"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Verify get after put option description.
|
||||
/// </summary>
|
||||
public static string VerifyGetAfterPutDescription { get { return LC.L("Verify the files after uploading them to ensure that they were uploaded correctly"); } }
|
||||
|
||||
}
|
||||
+67
-82
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Trigger modes for remote synchronization.
|
||||
@@ -60,12 +61,15 @@ public enum RemoteSyncTriggerMode
|
||||
/// Configuration for a single remote synchronization destination.
|
||||
/// </summary>
|
||||
public record RemoteSyncDestinationConfig(
|
||||
RemoteSynchronization.Config Config,
|
||||
RemoteSynchronizationConfig Config,
|
||||
RemoteSyncTriggerMode Mode = RemoteSyncTriggerMode.Inline,
|
||||
TimeSpan? Interval = null,
|
||||
int? Count = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Raw configuration for a remote synchronization destination, as deserialized from JSON.
|
||||
/// </summary>
|
||||
public record RemoteSyncDestinationConfigRaw(
|
||||
bool AutoCreateFolders = true,
|
||||
int BackendRetries = 3,
|
||||
@@ -93,6 +97,9 @@ public record RemoteSyncDestinationConfigRaw(
|
||||
public int? Count { get; init; }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Top-level configuration for remote synchronization, containing the destinations array and global settings.
|
||||
/// </summary>
|
||||
public record TopLevelRemoteSyncConfig(
|
||||
bool SyncOnWarnings = true
|
||||
)
|
||||
@@ -103,76 +110,52 @@ public record TopLevelRemoteSyncConfig(
|
||||
/// <summary>
|
||||
/// Module for synchronizing backup data to remote destinations after a successful backup operation.
|
||||
/// </summary>
|
||||
public class RemoteSynchronizationModule : IGenericCallbackModule
|
||||
internal class RemoteSynchronizationHandler : IDisposable
|
||||
{
|
||||
private static readonly string LOGTAG = Logging.Log.LogTagFromType<RemoteSynchronizationModule>();
|
||||
|
||||
private const string OPTION_JSON_CONFIG = "remote-sync-json-config";
|
||||
private static readonly string LOGTAG = Logging.Log.LogTagFromType<RemoteSynchronizationHandler>();
|
||||
|
||||
private string? m_dbpath;
|
||||
private bool m_enabled = false;
|
||||
private List<RemoteSyncDestinationConfig> m_destinations = [];
|
||||
private bool m_enabled;
|
||||
private string? m_operationName;
|
||||
private string? m_source;
|
||||
private string m_source;
|
||||
private bool m_syncOnWarnings = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the key identifier for this module.
|
||||
/// </summary>
|
||||
public string Key => "remotesync";
|
||||
/// <summary>
|
||||
/// Gets the display name for this module.
|
||||
/// </summary>
|
||||
public string DisplayName => Strings.RemoteSynchronization.DisplayName;
|
||||
/// <summary>
|
||||
/// Gets the description of this module.
|
||||
/// </summary>
|
||||
public string Description => Strings.RemoteSynchronization.Description;
|
||||
/// <summary>
|
||||
/// Gets whether this module should be loaded by default.
|
||||
/// </summary>
|
||||
public bool LoadAsDefault => true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of supported command line arguments.
|
||||
/// </summary>
|
||||
public IList<ICommandLineArgument> 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;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the module with the provided command line options.
|
||||
/// </summary>
|
||||
/// <param name="commandlineOptions">The command line options dictionary.</param>
|
||||
public void Configure(IDictionary<string, string> commandlineOptions)
|
||||
/// <param name="backendUrl">The URL of the source backend.</param>
|
||||
/// <param name="options">The command line options dictionary.</param>
|
||||
/// <param name="results">The backup results object, used to determine whether to run the synchronization and to report results.</param>
|
||||
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<RemoteSyncTriggerMode>(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
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when an operation starts.
|
||||
/// </summary>
|
||||
/// <param name="operationname">The name of the operation.</param>
|
||||
/// <param name="remoteurl">The remote URL.</param>
|
||||
/// <param name="localpath">The local paths.</param>
|
||||
public void OnStart(string operationname, ref string remoteurl, ref string[] localpath)
|
||||
/// <inheritdoc />
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.).
|
||||
/// </summary>
|
||||
/// <param name="result">The results of the operation.</param>
|
||||
/// <param name="exception">Any exception that occurred during the operation.</param>
|
||||
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<IRemoteSynchronizationResults>();
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
/// </summary>
|
||||
private static readonly string LOGTAG = Logging.Log.LogTagFromType<VolumeManager>();
|
||||
|
||||
/// <summary>
|
||||
/// Number of disk-pressure evictions that must occur before a mid-run warning is emitted.
|
||||
/// </summary>
|
||||
private const int CACHE_PRESSURE_WARNING_THRESHOLD = 5;
|
||||
|
||||
/// <summary>
|
||||
/// 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<long, VolumeWrapper> 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<long> cache_last_touched = [];
|
||||
// Dictionary to keep track of active downloads. Used for grouping requests to the same volume.
|
||||
Dictionary<long, List<BlockRequest>> in_flight_downloads = [];
|
||||
// Disk-pressure counters (unlimited cache mode only).
|
||||
HashSet<long> previously_evicted_volume_ids = [];
|
||||
HashSet<long> 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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -164,10 +164,15 @@ namespace Duplicati.Library.Main
|
||||
private static readonly int DEFAULT_RESTORE_FILE_PROCESSORS = Math.Max(1, Environment.ProcessorCount / 2);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="RestoreVolumeCacheMinFree"/>).
|
||||
/// </summary>
|
||||
private const string DEFAULT_RESTORE_VOLUME_CACHE_HINT = "";
|
||||
|
||||
/// <summary>
|
||||
/// The default minimum free disk space to maintain in the temp directory during restore (1 GB).
|
||||
/// </summary>
|
||||
private const string DEFAULT_RESTORE_VOLUME_CACHE_MIN_FREE = "1gb";
|
||||
|
||||
/// <summary>
|
||||
/// The default value for the number of volume decryptors during restore
|
||||
/// </summary>
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
@@ -1805,9 +1815,10 @@ namespace Duplicati.Library.Main
|
||||
/// </summary>
|
||||
public string? MacOSPhotoLibraryPath => GetString("photos-library-path", null);
|
||||
|
||||
// <summary>
|
||||
// Gets the size of the volume cache used during restore, in MB.
|
||||
// </summary>
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum free disk space (in bytes) to maintain in the temp directory during restore.
|
||||
/// Only used when <see cref="RestoreVolumeCacheHint"/> returns -1 (unlimited mode).
|
||||
/// </summary>
|
||||
public long RestoreVolumeCacheMinFree
|
||||
=> GetSize("restore-volume-cache-min-free", "gb", DEFAULT_RESTORE_VOLUME_CACHE_MIN_FREE);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of volume decryptors to use in the restore process
|
||||
/// </summary>
|
||||
|
||||
@@ -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
|
||||
/// <param name="fileoffset">Fileoffset.</param>
|
||||
void UpdateFile(out string filename, out long filesize, out long fileoffset, out bool filecomplete);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current remote sync destination index (1-based) and total count.
|
||||
/// Returns (0, 0) when not in a remote sync phase.
|
||||
/// </summary>
|
||||
/// <param name="destinationIndex">The 1-based index of the current destination being synced.</param>
|
||||
/// <param name="destinationCount">The total number of destinations to sync.</param>
|
||||
void UpdateRemoteSyncDestination(out int destinationIndex, out int destinationCount);
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the phase has changed
|
||||
/// </summary>
|
||||
@@ -367,7 +375,7 @@ namespace Duplicati.Library.Main
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for updating the backend progress
|
||||
/// Interface for updating the operation progress
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the current remote sync destination index and total count.
|
||||
/// </summary>
|
||||
/// <param name="destinationIndex">The 1-based index of the current destination being synced.</param>
|
||||
/// <param name="destinationCount">The total number of destinations to sync.</param>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IOperationProgress.UpdateRemoteSyncDestination(out int destinationIndex, out int destinationCount)
|
||||
{
|
||||
lock (m_lock)
|
||||
{
|
||||
destinationIndex = m_remoteSyncDestinationIndex;
|
||||
destinationCount = m_remoteSyncDestinationCount;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the phase, progress, filesprocessed, filesizeprocessed, filecount, filesize and countingfiles.
|
||||
/// </summary>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of LRU evictions triggered by low disk space in the temp directory
|
||||
/// (disk-pressure path, unlimited cache mode only).
|
||||
/// </summary>
|
||||
public long CachePressureEvictions { get; internal set; }
|
||||
public bool ShouldSerializeCachePressureEvictions() => CachePressureEvictions > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Number of volumes re-downloaded after being evicted due to disk pressure.
|
||||
/// </summary>
|
||||
public long CachePressureRedownloads { get; internal set; }
|
||||
public bool ShouldSerializeCachePressureRedownloads() => CachePressureRedownloads > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Total number of distinct dblock volumes accessed during the restore.
|
||||
/// </summary>
|
||||
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) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Results from a remote synchronization operation to a single destination.
|
||||
/// </summary>
|
||||
internal class RemoteSynchronizationResults : BasicResults, IRemoteSynchronizationResults
|
||||
{
|
||||
/// <summary>
|
||||
/// The destination URL or identifier.
|
||||
/// </summary>
|
||||
public string Destination { get; internal set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Number of files deleted from the destination.
|
||||
/// </summary>
|
||||
public long DeletedFileCount { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of files renamed at the destination (retention mode).
|
||||
/// </summary>
|
||||
public long RenamedFileCount { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of files copied to the destination.
|
||||
/// </summary>
|
||||
public long CopiedFileCount { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of files verified at the destination.
|
||||
/// </summary>
|
||||
public long VerifiedFileCount { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of files that failed verification.
|
||||
/// </summary>
|
||||
public long FailedVerificationCount { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total size of files copied in bytes.
|
||||
/// </summary>
|
||||
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; } }
|
||||
|
||||
@@ -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"); } }
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<ProjectReference Include="..\..\Localization\Duplicati.Library.Localization.csproj" />
|
||||
<ProjectReference Include="..\..\ResultSerialization\Duplicati.Library.ResultSerialization.csproj" />
|
||||
<ProjectReference Include="..\..\Backend\OAuthHelper\Duplicati.Library.OAuthHelper.csproj" />
|
||||
<ProjectReference Include="..\..\RemoteSynchronization\Duplicati.Library.RemoteSynchronization.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition=" $([System.Text.RegularExpressions.Regex]::IsMatch('$(RuntimeIdentifier)', '^win-')) Or '$(RuntimeIdentifier)' == '' ">
|
||||
|
||||
@@ -37,7 +37,6 @@ public static class GenericModules
|
||||
new CommonOptions(),
|
||||
new HyperVOptions(),
|
||||
new MSSQLOptions(),
|
||||
new RemoteSynchronizationModule(),
|
||||
new RunScript(),
|
||||
new SendHttpMessage(),
|
||||
new SendJabberMessage(),
|
||||
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>RemoteSynchronization</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
||||
<PackageReference Include="System.CommandLine.NamingConventionBinder" Version="2.0.0-beta4.22272.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Backends\Duplicati.Library.Backends.csproj" />
|
||||
<ProjectReference Include="..\Common\Duplicati.Library.Common.csproj" />
|
||||
<ProjectReference Include="..\Interface\Duplicati.Library.Interface.csproj" />
|
||||
<ProjectReference Include="..\Logging\Duplicati.Library.Logging.csproj" />
|
||||
<ProjectReference Include="..\Main\Duplicati.Library.Main.csproj" />
|
||||
<ProjectReference Include="..\Utility\Duplicati.Library.Utility.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Global configuration for the tool. Should be set after parsing the commandline arguments.
|
||||
/// </summary>
|
||||
public sealed record Config
|
||||
(
|
||||
// Arguments
|
||||
string Src,
|
||||
string Dst,
|
||||
|
||||
// Options
|
||||
bool AutoCreateFolders,
|
||||
int BackendRetries,
|
||||
int BackendRetryDelay,
|
||||
bool BackendRetryWithExponentialBackoff,
|
||||
bool Confirm,
|
||||
bool DryRun,
|
||||
List<string> DstOptions,
|
||||
bool Force,
|
||||
List<string> GlobalOptions,
|
||||
string LogFile,
|
||||
string LogLevel,
|
||||
bool ParseArgumentsOnly,
|
||||
bool Progress,
|
||||
bool Retention,
|
||||
int Retry,
|
||||
List<string> SrcOptions,
|
||||
bool VerifyContents,
|
||||
bool VerifyGetAfterPut
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Remote synchronization tool.
|
||||
/// </summary>
|
||||
public static class RemoteSynchronizationRunner
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// The log tag for this tool.
|
||||
/// </summary>
|
||||
private static readonly string LOGTAG = Duplicati.Library.Logging.Log.LogTagFromType(typeof(RemoteSynchronizationRunner));
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for running the tool logic with parsed arguments.
|
||||
/// </summary>
|
||||
/// <param name="args">The commandline arguments</param>
|
||||
/// <returns>0 on success, -1 on abort, and the number of errors encountered otherwise.</returns>
|
||||
public static async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
var arg_src = new Argument<string>(name: "backend_src", description: Strings.SourceBackendDescription);
|
||||
var arg_dst = new Argument<string>(name: "backend_dst", description: Strings.DestinationBackendDescription);
|
||||
|
||||
var root_cmd = new RootCommand(Strings.RootCommandDescription)
|
||||
{
|
||||
arg_src,
|
||||
arg_dst,
|
||||
|
||||
new Option<bool>(aliases: ["--auto-create-folders"], description: Strings.AutoCreateFoldersDescription, getDefaultValue: () => true),
|
||||
new Option<int>(aliases: ["--backend-retries"], description: Strings.BackendRetriesDescription, getDefaultValue: () => 3) { Arity = ArgumentArity.ExactlyOne },
|
||||
new Option<int>(aliases: ["--backend-retry-delay"], description: Strings.BackendRetryDelayDescription, getDefaultValue: () => 1000) { Arity = ArgumentArity.ExactlyOne },
|
||||
new Option<bool>(aliases: ["--backend-retry-with-exponential-backoff"], description: Strings.BackendRetryWithExponentialBackoffDescription, getDefaultValue: () => true),
|
||||
new Option<bool>(aliases: ["--confirm", "--yes", "-y"], description: Strings.ConfirmDescription, getDefaultValue: () => false),
|
||||
new Option<bool>(aliases: ["--dry-run", "-d"], description: Strings.DryRunDescription, getDefaultValue: () => false),
|
||||
OptionWithMultipleTokens(aliases: ["--dst-options"], description: Strings.DstOptionsDescription, getDefaultValue: () => []),
|
||||
new Option<bool>(aliases: ["--force", "-f"], description: Strings.ForceDescription, getDefaultValue: () => false),
|
||||
OptionWithMultipleTokens(aliases: ["--global-options"], description: Strings.GlobalOptionsDescription, getDefaultValue: () => []),
|
||||
new Option<string>(aliases: ["--log-file"], description: Strings.LogFileDescription, getDefaultValue: () => "") { Arity = ArgumentArity.ExactlyOne },
|
||||
new Option<string>(aliases: ["--log-level"], description: Strings.LogLevelDescription, getDefaultValue: () => "Information") { Arity = ArgumentArity.ExactlyOne },
|
||||
new Option<bool>(aliases: ["--parse-arguments-only"], description: Strings.ParseArgumentsOnlyDescription, getDefaultValue: () => false),
|
||||
new Option<bool>(aliases: ["--progress"], description: Strings.ProgressDescription, getDefaultValue: () => false),
|
||||
new Option<bool>(aliases: ["--retention"], description: Strings.RetentionDescription, getDefaultValue: () => false),
|
||||
new Option<int>(aliases: ["--retry"], description: Strings.RetryDescription, getDefaultValue: () => 3) { Arity = ArgumentArity.ExactlyOne },
|
||||
OptionWithMultipleTokens(aliases: ["--src-options"], description: Strings.SrcOptionsDescription, getDefaultValue: () => []),
|
||||
new Option<bool>(aliases: ["--verify-contents"], description: Strings.VerifyContentsDescription, getDefaultValue: () => false),
|
||||
new Option<bool>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The main logic of the tool.
|
||||
/// </summary>
|
||||
/// <param name="config">The parsed configuration for the tool.</param>
|
||||
/// <param name="token">The cancellation token to use for the asynchronous operations.</param>
|
||||
/// <returns>The return code for the main entry; 0 on success.</returns>
|
||||
public static async Task<int> Run(Config config, CancellationToken token)
|
||||
{
|
||||
// Parse the log level
|
||||
var log_level_parsed = Enum.TryParse<Duplicati.Library.Logging.LogMessageType>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The core logic of the tool, without logging setup.
|
||||
/// </summary>
|
||||
/// <param name="config">The parsed configuration for the tool.</param>
|
||||
/// <param name="token">The cancellation token to use for the asynchronous operations.</param>
|
||||
/// <returns>The return code for the main entry; 0 on success.</returns>
|
||||
private static async Task<int> 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
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="b_src">The source backend.</param>
|
||||
/// <param name="b_dst">The destination backend.</param>
|
||||
/// <param name="files">The files that will be copied.</param>
|
||||
/// <param name="config">The parsed configuration for the tool.</param>
|
||||
/// <param name="token">The cancellation token to use for the asynchronous operations.</param>
|
||||
/// <returns>A tuple holding the number of succesful copies and a List of the files that failed.</returns>
|
||||
private static async Task<(long, IEnumerable<IFileEntry>)> CopyAsync(LightWeightBackendManager b_src, LightWeightBackendManager b_dst, IEnumerable<IFileEntry> files, Config config, CancellationToken token)
|
||||
{
|
||||
long successful_copies = 0;
|
||||
List<IFileEntry> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the files from a backend.
|
||||
/// </summary>
|
||||
/// <param name="b">The backend to delete the files from.</param>
|
||||
/// <param name="files">The files to delete.</param>
|
||||
/// <param name="config">The parsed configuration for the tool.</param>
|
||||
/// <param name="token">The cancellation token to use for the asynchronous operations.</param>
|
||||
/// <returns>The number of successful deletions.</returns>
|
||||
private static async Task<long> DeleteAsync(LightWeightBackendManager b, IEnumerable<IFileEntry> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an option that allows multiple tokens and multiple arguments per token.
|
||||
/// </summary>
|
||||
/// <param name="aliases">The aliases for the option.</param>
|
||||
/// <param name="description">The description for the option.</param>
|
||||
/// <returns>The created option.</returns>
|
||||
private static Option<List<string>> OptionWithMultipleTokens(string[] aliases, string description, Func<List<string>> getDefaultValue)
|
||||
{
|
||||
return new Option<List<string>>(aliases: aliases, description: description, getDefaultValue: getDefaultValue)
|
||||
{
|
||||
Arity = ArgumentArity.OneOrMore,
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A log destination that writes to multiple destinations.
|
||||
/// </summary>
|
||||
private sealed class MultiLogDestination : ILogDestination, IDisposable
|
||||
{
|
||||
private readonly List<ILogDestination> m_destinations;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the MultiLogDestination class.
|
||||
/// </summary>
|
||||
/// <param name="destinations">The log destinations to write to.</param>
|
||||
public MultiLogDestination(IEnumerable<ILogDestination?> destinations)
|
||||
{
|
||||
m_destinations = destinations.Where(x => x != null).Cast<ILogDestination>().ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a log entry to all destinations.
|
||||
/// </summary>
|
||||
/// <param name="entry">The log entry to write.</param>
|
||||
public void WriteMessage(LogEntry entry)
|
||||
{
|
||||
foreach (var destination in m_destinations)
|
||||
destination.WriteMessage(entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes of the resources used by this instance.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var destination in m_destinations)
|
||||
if (destination is IDisposable disposable)
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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\"".
|
||||
/// </summary>
|
||||
/// <param name="options">The list of string options to parse</param>
|
||||
/// <returns>A dictionary with the parsed options, where the key is the option name and the value is the option value.</returns>
|
||||
/// <exception cref="ArgumentException">If an option was not parsed correctly.</exception>
|
||||
private static Dictionary<string, string> ParseOptions(IEnumerable<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="b_src">The source lightweight backend manager.</param>
|
||||
/// <param name="b_dst">The destination lightweight backend manager.</param>
|
||||
/// <param name="config">The parsed configuration for the tool.</param>
|
||||
/// <param name="token">The cancellation token to use for the asynchronous operations.</param>
|
||||
/// <returns>A tuple of Lists each holding the files to copy, delete and verify.</returns>
|
||||
private static async Task<(IEnumerable<IFileEntry>, IEnumerable<IFileEntry>, IEnumerable<IFileEntry>)> PrepareFileLists(LightWeightBackendManager b_src, LightWeightBackendManager b_dst, Config config, CancellationToken token)
|
||||
{
|
||||
IEnumerable<IFileEntry> 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<string, IFileEntry> 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<IFileEntry>();
|
||||
var to_delete = new HashSet<string>();
|
||||
var to_verify = new List<IFileEntry>();
|
||||
|
||||
// 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<IFileEntry> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renames the files in a backend.
|
||||
/// The renaming is done by deleting the file and re-uploading it with a new name.
|
||||
/// </summary>
|
||||
/// <param name="bm">The lightweight backend manager to issue rename operations to.</param>
|
||||
/// <param name="files">The files to rename.</param>
|
||||
/// <param name="config">The parsed configuration for the tool.</param>
|
||||
/// <param name="token">The cancellation token to use for the asynchronous operations.</param>
|
||||
/// <returns>The number of successful renames.</returns>
|
||||
private static async Task<long> RenameAsync(LightWeightBackendManager bm, IEnumerable<IFileEntry> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="b_src">The source lightweight backend manager.</param>
|
||||
/// <param name="b_dst">The destination lightweight backend manager.</param>
|
||||
/// <param name="files">The files to verify.</param>
|
||||
/// <param name="config">The parsed configuration for the tool.</param>
|
||||
/// <param name="token">The cancellation token to use for the asynchronous operations.</param>
|
||||
/// <returns>A list of the files that failed verification.</returns>
|
||||
private static async Task<IEnumerable<IFileEntry>> VerifyAsync(LightWeightBackendManager b_src, LightWeightBackendManager b_dst, IEnumerable<IFileEntry> files, Config config, CancellationToken token)
|
||||
{
|
||||
var errors = new List<IFileEntry>();
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Localized strings for the Remote Synchronization tool.
|
||||
/// </summary>
|
||||
public static class Strings
|
||||
{
|
||||
/// <summary>
|
||||
/// Root command description.
|
||||
/// </summary>
|
||||
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).
|
||||
"); } }
|
||||
|
||||
/// <summary>
|
||||
/// The source backend string argument description.
|
||||
/// </summary>
|
||||
public static string SourceBackendDescription { get { return LC.L("The source backend string"); } }
|
||||
|
||||
/// <summary>
|
||||
/// The destination backend string argument description.
|
||||
/// </summary>
|
||||
public static string DestinationBackendDescription { get { return LC.L("The destination backend string"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Auto-create folders option description.
|
||||
/// </summary>
|
||||
public static string AutoCreateFoldersDescription { get { return LC.L("Automatically create folders in the destination backend if they do not exist"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Backend retries option description.
|
||||
/// </summary>
|
||||
public static string BackendRetriesDescription { get { return LC.L("Number of times to recreate a backend on backend errors"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Backend retry delay option description.
|
||||
/// </summary>
|
||||
public static string BackendRetryDelayDescription { get { return LC.L("Delay in milliseconds between backend retries"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Backend retry with exponential backoff option description.
|
||||
/// </summary>
|
||||
public static string BackendRetryWithExponentialBackoffDescription { get { return LC.L("Use exponential backoff for backend retries, multiplying the delay by two for each failure."); } }
|
||||
|
||||
/// <summary>
|
||||
/// Confirm option description.
|
||||
/// </summary>
|
||||
public static string ConfirmDescription { get { return LC.L("Automatically confirm the operation"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Dry-run option description.
|
||||
/// </summary>
|
||||
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"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Destination options option description.
|
||||
/// </summary>
|
||||
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]"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Force option description.
|
||||
/// </summary>
|
||||
public static string ForceDescription { get { return LC.L("Force the synchronization"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Global options option description.
|
||||
/// </summary>
|
||||
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]"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Log file option description.
|
||||
/// </summary>
|
||||
public static string LogFileDescription { get { return LC.L("The log file to write to. If not set here, global options will be checked [default: \"\"]"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Log level option description.
|
||||
/// </summary>
|
||||
public static string LogLevelDescription { get { return LC.L("The log level to use. If not set here, global options will be checked"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Parse arguments only option description.
|
||||
/// </summary>
|
||||
public static string ParseArgumentsOnlyDescription { get { return LC.L("Only parse the arguments and then exit"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Progress option description.
|
||||
/// </summary>
|
||||
public static string ProgressDescription { get { return LC.L("Print progress to STDOUT"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Retention option description.
|
||||
/// </summary>
|
||||
public static string RetentionDescription { get { return LC.L("Toggles whether to keep old files. Any deletes will be renames instead"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Retry option description.
|
||||
/// </summary>
|
||||
public static string RetryDescription { get { return LC.L("Number of times to retry on errors"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Source options option description.
|
||||
/// </summary>
|
||||
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]"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Verify contents option description.
|
||||
/// </summary>
|
||||
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"); } }
|
||||
|
||||
/// <summary>
|
||||
/// Verify get after put option description.
|
||||
/// </summary>
|
||||
public static string VerifyGetAfterPutDescription { get { return LC.L("Verify the files after uploading them to ensure that they were uploaded correctly"); } }
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Representation of the current active transfer.
|
||||
/// </summary>
|
||||
/// <param name="BackendAction">The action being performed by the backend</param>
|
||||
/// <param name="BackendPath">The path of the file being transferred</param>
|
||||
/// <param name="BackendFileSize">The total size of the file being transferred</param>
|
||||
/// <param name="BackendFileProgress">The current progress of the file transfer</param>
|
||||
/// <param name="BackendSpeed">The current speed of the file transfer</param>
|
||||
/// <param name="BackendIsBlocking">Indicates if the backend operation is blocking</param>
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Representation of the current active transfer.
|
||||
/// </summary>
|
||||
/// <param name="BackendAction">The action being performed by the backend</param>
|
||||
/// <param name="BackendPath">The path of the file being transferred</param>
|
||||
/// <param name="BackendFileSize">The total size of the file being transferred</param>
|
||||
/// <param name="BackendFileProgress">The current progress of the file transfer</param>
|
||||
/// <param name="BackendSpeed">The current speed of the file transfer</param>
|
||||
/// <param name="BackendIsBlocking">Indicates if the backend operation is blocking</param>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// The 1-based index of the current remote sync destination being processed.
|
||||
/// 0 when not in a remote sync phase.
|
||||
/// </summary>
|
||||
int RemoteSyncDestinationIndex { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The total number of remote sync destinations to process.
|
||||
/// 0 when not in a remote sync phase.
|
||||
/// </summary>
|
||||
int RemoteSyncDestinationCount { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
});
|
||||
|
||||
|
||||
+4
-4
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"description": "Modern Angular Client for Duplicati",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@duplicati/ngclient": "^0.0.212"
|
||||
"@duplicati/ngclient": "^0.0.213"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Factory class for creating the appropriate <see cref="IDiskImageHelper"/> implementation
|
||||
/// based on the host operating system.
|
||||
/// </summary>
|
||||
public static class DiskImageHelperFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates and returns the appropriate disk image helper for the current operating system.
|
||||
/// </summary>
|
||||
/// <returns>An <see cref="IDiskImageHelper"/> implementation for the current OS.</returns>
|
||||
/// <exception cref="PlatformNotSupportedException">Thrown when the current OS is not supported.</exception>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether disk image operations are supported on the current platform.
|
||||
/// </summary>
|
||||
/// <value><c>true</c> if disk image operations are supported; otherwise, <c>false</c>.</value>
|
||||
public static bool IsSupported
|
||||
{
|
||||
get
|
||||
{
|
||||
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
|| RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
|
||||
|| RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the current platform.
|
||||
/// </summary>
|
||||
/// <value>The platform name ("Windows", "Linux", "macOS", or "Unknown").</value>
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper methods for disk image unit tests.
|
||||
/// Provides common operations for creating, initializing, and managing disk images.
|
||||
/// </summary>
|
||||
internal static class DiskImageTestHelpers
|
||||
{
|
||||
private const long MiB = 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a raw disk interface for the specified disk identifier based on the current platform.
|
||||
/// </summary>
|
||||
/// <param name="diskIdentifier">The disk identifier (e.g., \\.\PhysicalDriveN on Windows, /dev/loopN on Linux).</param>
|
||||
/// <returns>An initialized IRawDisk instance for the specified platform.</returns>
|
||||
/// <exception cref="PlatformNotSupportedException">Thrown when the current OS is not supported.</exception>
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a disk image file with the specified parameters.
|
||||
/// </summary>
|
||||
/// <param name="diskHelper">The disk image helper.</param>
|
||||
/// <param name="imagePath">The path where the disk image will be created.</param>
|
||||
/// <param name="size">The size of the disk in bytes.</param>
|
||||
/// <param name="tableType">The partition table type (GPT, MBR, etc.).</param>
|
||||
/// <param name="partitions">Array of tuples specifying filesystem type and size for each partition.</param>
|
||||
/// <returns>The disk identifier for the created disk.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills a partition with well-known test data (repeating pattern of 0x00-0xFF).
|
||||
/// </summary>
|
||||
/// <param name="rawDisk">The raw disk interface.</param>
|
||||
/// <param name="partitionOffset">The starting offset of the partition in bytes.</param>
|
||||
/// <param name="partitionSize">The size of the partition in bytes.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the file extension for disk images based on the current platform.
|
||||
/// </summary>
|
||||
/// <returns>The platform-specific disk image extension (vhdx, img, or dmg).</returns>
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a file if it exists, catching any exceptions.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The path to the file to delete.</param>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Legacy;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.Fat32;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="Fat32BootSector"/> parser.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[Category("DiskImageUnit")]
|
||||
public class Fat32BootSectorTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a valid FAT32 boot sector with known values for testing.
|
||||
/// </summary>
|
||||
private static byte[] CreateValidBootSector()
|
||||
{
|
||||
var bootSector = new byte[512];
|
||||
|
||||
// Bytes per sector at offset 0x0B (512 = 0x0200)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0B, 2), 512);
|
||||
|
||||
// Sectors per cluster at offset 0x0D (8 sectors)
|
||||
bootSector[0x0D] = 8;
|
||||
|
||||
// Reserved sector count at offset 0x0E (32 sectors)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0E, 2), 32);
|
||||
|
||||
// Number of FATs at offset 0x10 (2 FATs)
|
||||
bootSector[0x10] = 2;
|
||||
|
||||
// Total sectors 32 at offset 0x20 (1,000,000 sectors = ~512MB)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x20, 4), 1_000_000);
|
||||
|
||||
// FAT size 32 at offset 0x24 (244 sectors per FAT)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x24, 4), 244);
|
||||
|
||||
// Root cluster at offset 0x2C (cluster 2)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x2C, 4), 2);
|
||||
|
||||
// FSInfo sector at offset 0x30 (sector 1)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x30, 2), 1);
|
||||
|
||||
// Filesystem type string at offset 0x52 (8 bytes: "FAT32 ")
|
||||
var fsType = Encoding.ASCII.GetBytes("FAT32 ");
|
||||
fsType.CopyTo(bootSector, 0x52);
|
||||
|
||||
// Boot sector signature at offset 510: bytes 0x55, 0xAA on disk (0xAA55 as little-endian uint16)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(510, 2), 0xAA55);
|
||||
|
||||
return bootSector;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32BootSector_ParseValidBootSector()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
Assert.AreEqual(512, bootSector.BytesPerSector);
|
||||
Assert.AreEqual(8, bootSector.SectorsPerCluster);
|
||||
Assert.AreEqual(32, bootSector.ReservedSectorCount);
|
||||
Assert.AreEqual(2, bootSector.NumberOfFats);
|
||||
Assert.AreEqual(1_000_000u, bootSector.TotalSectors32);
|
||||
Assert.AreEqual(244u, bootSector.FatSize32);
|
||||
Assert.AreEqual(2u, bootSector.RootCluster);
|
||||
Assert.AreEqual(1, bootSector.FsInfoSector);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32BootSector_InvalidSignature_Throws()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
// Change the signature to an invalid value
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSectorData.AsSpan(510, 2), 0x1234);
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Fat32BootSector(bootSectorData));
|
||||
StringAssert.Contains("signature", ex!.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32BootSector_InvalidBytesPerSector_Throws()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
// Set bytes per sector to 768 (not a power of 2)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSectorData.AsSpan(0x0B, 2), 768);
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Fat32BootSector(bootSectorData));
|
||||
StringAssert.Contains("BytesPerSector", ex!.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32BootSector_InvalidSectorsPerCluster_Throws()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
// Set sectors per cluster to 3 (not a power of 2)
|
||||
bootSectorData[0x0D] = 3;
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Fat32BootSector(bootSectorData));
|
||||
StringAssert.Contains("SectorsPerCluster", ex!.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32BootSector_DerivedProperties_Correct()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// ClusterSize = BytesPerSector * SectorsPerCluster = 512 * 8 = 4096
|
||||
Assert.AreEqual(4096, bootSector.ClusterSize);
|
||||
|
||||
// FatStartOffset = ReservedSectorCount * BytesPerSector = 32 * 512 = 16384
|
||||
Assert.AreEqual(16384, bootSector.FatStartOffset);
|
||||
|
||||
// DataStartOffset = (ReservedSectorCount + NumberOfFats * FatSize32) * BytesPerSector
|
||||
// = (32 + 2 * 244) * 512 = (32 + 488) * 512 = 520 * 512 = 266240
|
||||
Assert.AreEqual(266240, bootSector.DataStartOffset);
|
||||
|
||||
// TotalDataClusters = (TotalSectors32 - (ReservedSectorCount + NumberOfFats * FatSize32)) / SectorsPerCluster
|
||||
// = (1,000,000 - (32 + 2 * 244)) / 8 = (1,000,000 - 520) / 8 = 999,480 / 8 = 124935
|
||||
Assert.AreEqual(124935u, bootSector.TotalDataClusters);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32BootSector_ClusterToByteOffset_Correct()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Cluster 2 should be at DataStartOffset (first data cluster)
|
||||
Assert.AreEqual(bootSector.DataStartOffset, bootSector.ClusterToByteOffset(2));
|
||||
|
||||
// Cluster 3 should be at DataStartOffset + ClusterSize
|
||||
Assert.AreEqual(bootSector.DataStartOffset + bootSector.ClusterSize, bootSector.ClusterToByteOffset(3));
|
||||
|
||||
// Cluster 10 should be at DataStartOffset + 8 * ClusterSize
|
||||
Assert.AreEqual(bootSector.DataStartOffset + 8 * bootSector.ClusterSize, bootSector.ClusterToByteOffset(10));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32BootSector_TooSmallBuffer_Throws()
|
||||
{
|
||||
var smallBuffer = new byte[256];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Fat32BootSector(smallBuffer));
|
||||
StringAssert.Contains("512", ex!.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32BootSector_ClusterToByteOffset_InvalidCluster_Throws()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => bootSector.ClusterToByteOffset(0));
|
||||
StringAssert.Contains("Cluster numbers start at 2", ex!.Message);
|
||||
|
||||
ex = Assert.Throws<ArgumentException>(() => bootSector.ClusterToByteOffset(1));
|
||||
StringAssert.Contains("Cluster numbers start at 2", ex!.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32BootSector_NumberOfFats_Zero_Throws()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
bootSectorData[0x10] = 0; // Set NumberOfFats to 0
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Fat32BootSector(bootSectorData));
|
||||
StringAssert.Contains("NumberOfFats", ex!.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32BootSector_InvalidFilesystemType_Throws()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
// Change filesystem type to something other than FAT32
|
||||
var fsType = Encoding.ASCII.GetBytes("FAT16 ");
|
||||
fsType.CopyTo(bootSectorData, 0x52);
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Fat32BootSector(bootSectorData));
|
||||
StringAssert.Contains("FAT32", ex!.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32BootSector_ValidBytesPerSectorValues()
|
||||
{
|
||||
ushort[] validValues = { 512, 1024, 2048, 4096 };
|
||||
|
||||
foreach (var bytesPerSector in validValues)
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSectorData.AsSpan(0x0B, 2), bytesPerSector);
|
||||
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
Assert.AreEqual(bytesPerSector, bootSector.BytesPerSector);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32BootSector_ValidSectorsPerClusterValues()
|
||||
{
|
||||
byte[] validValues = { 1, 2, 4, 8, 16, 32, 64, 128 };
|
||||
|
||||
foreach (var sectorsPerCluster in validValues)
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
bootSectorData[0x0D] = sectorsPerCluster;
|
||||
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
Assert.AreEqual(sectorsPerCluster, bootSector.SectorsPerCluster);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32BootSector_LargerBuffer_Works()
|
||||
{
|
||||
// Test that a buffer larger than 512 bytes works (e.g., 4KB sector size)
|
||||
var bootSectorData = new byte[4096];
|
||||
var validData = CreateValidBootSector();
|
||||
validData.CopyTo(bootSectorData, 0);
|
||||
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
Assert.AreEqual(512, bootSector.BytesPerSector);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Legacy;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.Fat32;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="Fat32DirectoryWalker"/> directory entry walker.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[Category("DiskImageUnit")]
|
||||
public class Fat32DirectoryWalkerTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a valid FAT32 boot sector with known values for testing.
|
||||
/// </summary>
|
||||
private static byte[] CreateValidBootSector()
|
||||
{
|
||||
var bootSector = new byte[512];
|
||||
|
||||
// Bytes per sector at offset 0x0B (512 = 0x0200)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0B, 2), 512);
|
||||
|
||||
// Sectors per cluster at offset 0x0D (8 sectors = 4KB clusters)
|
||||
bootSector[0x0D] = 8;
|
||||
|
||||
// Reserved sector count at offset 0x0E (32 sectors)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0E, 2), 32);
|
||||
|
||||
// Number of FATs at offset 0x10 (2 FATs)
|
||||
bootSector[0x10] = 2;
|
||||
|
||||
// Total sectors 32 at offset 0x20 (10,000 sectors = ~5MB)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x20, 4), 10_000);
|
||||
|
||||
// FAT size 32 at offset 0x24 (10 sectors per FAT)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x24, 4), 10);
|
||||
|
||||
// Root cluster at offset 0x2C (cluster 2)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x2C, 4), 2);
|
||||
|
||||
// FSInfo sector at offset 0x30 (sector 1)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x30, 2), 1);
|
||||
|
||||
// Filesystem type string at offset 0x52 (8 bytes: "FAT32 ")
|
||||
var fsType = System.Text.Encoding.ASCII.GetBytes("FAT32 ");
|
||||
fsType.CopyTo(bootSector, 0x52);
|
||||
|
||||
// Boot sector signature at offset 510: bytes 0x55, 0xAA on disk (0xAA55 as little-endian uint16)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(510, 2), 0xAA55);
|
||||
|
||||
return bootSector;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates FAT data with specified entries.
|
||||
/// </summary>
|
||||
private static byte[] CreateFatData(params uint[] entries)
|
||||
{
|
||||
var fatData = new byte[entries.Length * 4];
|
||||
for (int i = 0; i < entries.Length; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(fatData.AsSpan(i * 4, 4), entries[i]);
|
||||
}
|
||||
return fatData;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a directory entry (32 bytes) with the specified parameters.
|
||||
/// </summary>
|
||||
private static byte[] CreateDirectoryEntry(
|
||||
string filename,
|
||||
byte attributes,
|
||||
ushort writeDate,
|
||||
ushort writeTime,
|
||||
ushort clusterHigh,
|
||||
ushort clusterLow,
|
||||
uint fileSize = 0)
|
||||
{
|
||||
var entry = new byte[32];
|
||||
|
||||
// Filename (11 bytes: 8 for name, 3 for extension)
|
||||
var nameBytes = System.Text.Encoding.ASCII.GetBytes(filename.PadRight(11).Substring(0, 11));
|
||||
nameBytes.CopyTo(entry, 0);
|
||||
|
||||
// Attributes at offset 0x0B
|
||||
entry[0x0B] = attributes;
|
||||
|
||||
// Write time at offset 0x16
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(entry.AsSpan(0x16, 2), writeTime);
|
||||
|
||||
// Write date at offset 0x18
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(entry.AsSpan(0x18, 2), writeDate);
|
||||
|
||||
// Cluster high at offset 0x14
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(entry.AsSpan(0x14, 2), clusterHigh);
|
||||
|
||||
// Cluster low at offset 0x1A
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(entry.AsSpan(0x1A, 2), clusterLow);
|
||||
|
||||
// File size at offset 0x1C
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(entry.AsSpan(0x1C, 4), fileSize);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32DirectoryWalker_ParseTimestamp_Correct()
|
||||
{
|
||||
// Test known FAT32 date/time values
|
||||
// Date: 2023-06-15 = (2023-1980) << 9 | 6 << 5 | 15 = 43 << 9 | 192 | 15 = 22080 + 192 + 15 = 22287
|
||||
// Time: 14:30:20 = 14 << 11 | 30 << 5 | 10 = 28672 + 960 + 10 = 29642
|
||||
var date = (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15);
|
||||
var time = (ushort)((14 << 11) | (30 << 5) | (20 / 2));
|
||||
|
||||
var result = Fat32DirectoryWalker.ParseDateTime(date, time);
|
||||
|
||||
Assert.AreEqual(2023, result.Year);
|
||||
Assert.AreEqual(6, result.Month);
|
||||
Assert.AreEqual(15, result.Day);
|
||||
Assert.AreEqual(14, result.Hour);
|
||||
Assert.AreEqual(30, result.Minute);
|
||||
Assert.AreEqual(20, result.Second);
|
||||
Assert.AreEqual(DateTimeKind.Utc, result.Kind);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32DirectoryWalker_ParseTimestamp_Zero_ReturnsEpoch()
|
||||
{
|
||||
var result = Fat32DirectoryWalker.ParseDateTime(0, 0);
|
||||
Assert.AreEqual(DateTime.UnixEpoch, result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32DirectoryWalker_ParseTimestamp_InvalidDate_ReturnsEpoch()
|
||||
{
|
||||
// Invalid month (13)
|
||||
var invalidDate = (ushort)((43 << 9) | (13 << 5) | 15);
|
||||
var time = (ushort)((14 << 11) | (30 << 5) | 10);
|
||||
|
||||
var result = Fat32DirectoryWalker.ParseDateTime(invalidDate, time);
|
||||
Assert.AreEqual(DateTime.UnixEpoch, result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32DirectoryWalker_ParseTimestamp_InvalidTime_ReturnsEpoch()
|
||||
{
|
||||
// Valid date but invalid hour (25)
|
||||
var date = (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15);
|
||||
var invalidTime = (ushort)((25 << 11) | (30 << 5) | 10);
|
||||
|
||||
var result = Fat32DirectoryWalker.ParseDateTime(date, invalidTime);
|
||||
Assert.AreEqual(DateTime.UnixEpoch, result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32DirectoryWalker_SkipDeletedEntries()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create a FAT where cluster 2 (root) points to EOC, with enough entries for clusters 3 and 4
|
||||
var fatData = CreateFatData(
|
||||
0x00000000, // Cluster 0: reserved
|
||||
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
|
||||
0x0FFFFFFF, // Cluster 2: root directory (EOC)
|
||||
0x0FFFFFFF, // Cluster 3: valid entry (EOC)
|
||||
0x0FFFFFFF // Cluster 4: valid entry (EOC)
|
||||
);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
// Create root directory cluster data with a deleted entry followed by a valid entry
|
||||
var deletedEntry = CreateDirectoryEntry(
|
||||
"DELETEDTXT",
|
||||
0x00, // Regular file
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 15),
|
||||
(ushort)((14 << 11) | (30 << 5) | 10),
|
||||
0,
|
||||
3, // Would point to cluster 3
|
||||
100);
|
||||
deletedEntry[0] = 0xE5; // Mark as deleted
|
||||
|
||||
var validEntry = CreateDirectoryEntry(
|
||||
"VALID FIL",
|
||||
0x00, // Regular file
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
|
||||
(ushort)((10 << 11) | (0 << 5) | 0),
|
||||
0,
|
||||
4, // Points to cluster 4
|
||||
200);
|
||||
|
||||
var clusterData = new Dictionary<uint, byte[]>
|
||||
{
|
||||
[2] = [.. deletedEntry, .. validEntry, .. new byte[4096 - 64]]
|
||||
};
|
||||
|
||||
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
|
||||
|
||||
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
|
||||
|
||||
// Should only have cluster 4 mapped (cluster 3 should not be mapped as the entry was deleted)
|
||||
Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(3), "Deleted entry's cluster should not be mapped");
|
||||
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(4), "Valid entry's cluster should be mapped");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32DirectoryWalker_StopAtEndMarker()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create a FAT where cluster 2 (root) points to EOC, with enough entries for clusters 3 and 4
|
||||
var fatData = CreateFatData(
|
||||
0x00000000, // Cluster 0: reserved
|
||||
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
|
||||
0x0FFFFFFF, // Cluster 2: root directory (EOC)
|
||||
0x0FFFFFFF, // Cluster 3: valid entry (EOC)
|
||||
0x0FFFFFFF // Cluster 4: valid entry (EOC)
|
||||
);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
// Create root directory cluster data with an end marker followed by a valid entry
|
||||
var validEntry1 = CreateDirectoryEntry(
|
||||
"FILE1 TXT",
|
||||
0x00, // Regular file
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 15),
|
||||
(ushort)((14 << 11) | (30 << 5) | 10),
|
||||
0,
|
||||
3, // Points to cluster 3
|
||||
100);
|
||||
|
||||
var endMarker = new byte[32]; // All zeros including first byte = end of directory
|
||||
|
||||
var validEntry2 = CreateDirectoryEntry(
|
||||
"FILE2 TXT",
|
||||
0x00, // Regular file
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
|
||||
(ushort)((10 << 11) | (0 << 5) | 0),
|
||||
0,
|
||||
4, // Points to cluster 4
|
||||
200);
|
||||
|
||||
var clusterData = new Dictionary<uint, byte[]>
|
||||
{
|
||||
[2] = [.. validEntry1, .. endMarker, .. validEntry2, .. new byte[4096 - 96]]
|
||||
};
|
||||
|
||||
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
|
||||
|
||||
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
|
||||
|
||||
// Should only have cluster 3 mapped (FILE2 after end marker should be ignored)
|
||||
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(3), "Entry before end marker should be mapped");
|
||||
Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(4), "Entry after end marker should not be mapped");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32DirectoryWalker_SkipLfnEntries()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create a FAT where cluster 2 (root) points to EOC, with enough entries for cluster 3
|
||||
var fatData = CreateFatData(
|
||||
0x00000000, // Cluster 0: reserved
|
||||
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
|
||||
0x0FFFFFFF, // Cluster 2: root directory (EOC)
|
||||
0x0FFFFFFF // Cluster 3: valid entry (EOC)
|
||||
);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
// Create root directory cluster data with an LFN entry followed by a valid short entry
|
||||
var lfnEntry = CreateDirectoryEntry(
|
||||
"LFN_ENTRY ",
|
||||
0x0F, // Long filename attribute
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0);
|
||||
|
||||
var validEntry = CreateDirectoryEntry(
|
||||
"VALID FIL",
|
||||
0x00, // Regular file
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
|
||||
(ushort)((10 << 11) | (0 << 5) | 0),
|
||||
0,
|
||||
3, // Points to cluster 3
|
||||
200);
|
||||
|
||||
var clusterData = new Dictionary<uint, byte[]>
|
||||
{
|
||||
[2] = [.. lfnEntry, .. validEntry, .. new byte[4096 - 64]]
|
||||
};
|
||||
|
||||
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
|
||||
|
||||
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
|
||||
|
||||
// Should only have cluster 3 mapped (LFN entry should be skipped)
|
||||
Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(0), "LFN entry should not create mapping");
|
||||
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(3), "Valid entry's cluster should be mapped");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32DirectoryWalker_SkipDotEntries()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create a FAT where cluster 2 (root) and cluster 3 (subdir) both point to EOC, with enough entries for cluster 4
|
||||
var fatData = CreateFatData(
|
||||
0x00000000, // Cluster 0: reserved
|
||||
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
|
||||
0x0FFFFFFF, // Cluster 2: root directory (EOC)
|
||||
0x0FFFFFFF, // Cluster 3: subdirectory (EOC)
|
||||
0x0FFFFFFF // Cluster 4: valid entry (EOC)
|
||||
);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
// Create root directory with a subdirectory entry
|
||||
var subdirEntry = CreateDirectoryEntry(
|
||||
"SUBDIR ",
|
||||
0x10, // Directory attribute
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 15),
|
||||
(ushort)((14 << 11) | (30 << 5) | 10),
|
||||
0,
|
||||
3, // Points to cluster 3
|
||||
0);
|
||||
|
||||
var rootClusterData = subdirEntry.Concat(new byte[4096 - 32]).ToArray();
|
||||
|
||||
// Create subdirectory cluster with . and .. entries and a file entry
|
||||
var dotEntry = CreateDirectoryEntry(
|
||||
". ",
|
||||
0x10, // Directory attribute
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 15),
|
||||
(ushort)((14 << 11) | (30 << 5) | 10),
|
||||
0,
|
||||
3, // Points to self (cluster 3)
|
||||
0);
|
||||
|
||||
var dotDotEntry = CreateDirectoryEntry(
|
||||
".. ",
|
||||
0x10, // Directory attribute
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 15),
|
||||
(ushort)((14 << 11) | (30 << 5) | 10),
|
||||
0,
|
||||
2, // Points to parent (cluster 2)
|
||||
0);
|
||||
|
||||
var fileEntry = CreateDirectoryEntry(
|
||||
"FILE TXT",
|
||||
0x00, // Regular file
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
|
||||
(ushort)((10 << 11) | (0 << 5) | 0),
|
||||
0,
|
||||
4, // Points to cluster 4
|
||||
100);
|
||||
|
||||
var subdirClusterData = dotEntry.Concat(dotDotEntry).Concat(fileEntry).Concat(new byte[4096 - 96]).ToArray();
|
||||
|
||||
var clusterData = new Dictionary<uint, byte[]>
|
||||
{
|
||||
[2] = rootClusterData,
|
||||
[3] = subdirClusterData
|
||||
};
|
||||
|
||||
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
|
||||
|
||||
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
|
||||
|
||||
// Cluster 2 should be mapped (it's the root directory)
|
||||
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(2), "Root directory cluster should be mapped");
|
||||
|
||||
// Cluster 3 should be mapped (it's the subdirectory itself)
|
||||
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(3), "Subdirectory cluster should be mapped");
|
||||
|
||||
// Cluster 4 should be mapped (it's the file in the subdirectory)
|
||||
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(4), "File cluster should be mapped");
|
||||
|
||||
// We should have exactly 3 mappings (clusters 2, 3, and 4)
|
||||
Assert.AreEqual(3, walker.ClusterToTimestampMap.Count, "Should only have 3 cluster mappings");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32DirectoryWalker_ExtractClusterNumber()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create a FAT where cluster 2 (root) and cluster 0x1234ABCD both point to EOC
|
||||
var fatData = CreateFatData(
|
||||
0x00000000, // Cluster 0: reserved
|
||||
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
|
||||
0x0FFFFFFF, // Cluster 2: root directory (EOC)
|
||||
0x00000000, // Cluster 3: free (not used in this test)
|
||||
0x00000000, // ... more free clusters
|
||||
0x00000000,
|
||||
0x00000000,
|
||||
0x00000000,
|
||||
0x00000000,
|
||||
0x00000000,
|
||||
0x0FFFFFFF // Cluster 10 (0x0A): high cluster test (EOC)
|
||||
);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
// Test with a file entry that has a large cluster number requiring both high and low words
|
||||
// Cluster 0x0A = 10 (within our small FAT)
|
||||
var fileEntry = CreateDirectoryEntry(
|
||||
"TEST FIL",
|
||||
0x00, // Regular file
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
|
||||
(ushort)((10 << 11) | (0 << 5) | 0),
|
||||
0x0000, // High word
|
||||
0x000A, // Low word = 10
|
||||
100);
|
||||
|
||||
var clusterData = new Dictionary<uint, byte[]>
|
||||
{
|
||||
[2] = [.. fileEntry, .. new byte[4096 - 32]]
|
||||
};
|
||||
|
||||
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
|
||||
|
||||
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
|
||||
|
||||
// Cluster 10 should be mapped
|
||||
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(10), "Cluster with combined high/low words should be mapped");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32DirectoryWalker_ClusterTimestampMap_Correct()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create a FAT with cluster chains
|
||||
var fatData = CreateFatData(
|
||||
0x00000000, // Cluster 0: reserved
|
||||
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
|
||||
0x0FFFFFFF, // Cluster 2: root directory (EOC)
|
||||
0x00000004, // Cluster 3: points to cluster 4
|
||||
0x0FFFFFFF, // Cluster 4: EOC (file data)
|
||||
0x00000006, // Cluster 5: points to cluster 6
|
||||
0x00000007, // Cluster 6: points to cluster 7
|
||||
0x0FFFFFFF // Cluster 7: EOC (file data)
|
||||
);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
// Create root directory with two file entries
|
||||
var file1Entry = CreateDirectoryEntry(
|
||||
"FILE1 TXT",
|
||||
0x00, // Regular file
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 15),
|
||||
(ushort)((14 << 11) | (30 << 5) | 10),
|
||||
0,
|
||||
3, // Points to cluster 3 (chain: 3 -> 4)
|
||||
8192); // 2 clusters worth of data
|
||||
|
||||
var file2Entry = CreateDirectoryEntry(
|
||||
"FILE2 DAT",
|
||||
0x00, // Regular file
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
|
||||
(ushort)((16 << 11) | (45 << 5) | (30 / 2)),
|
||||
0,
|
||||
5, // Points to cluster 5 (chain: 5 -> 6 -> 7)
|
||||
12288); // 3 clusters worth of data
|
||||
|
||||
var clusterData = new Dictionary<uint, byte[]>
|
||||
{
|
||||
[2] = [.. file1Entry, .. file2Entry, .. new byte[4096 - 64]]
|
||||
};
|
||||
|
||||
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
|
||||
|
||||
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
|
||||
|
||||
// Should have mappings for all file clusters (3, 4, 5, 6, 7) plus root directory (2)
|
||||
Assert.AreEqual(6, walker.ClusterToTimestampMap.Count, "Should have 6 cluster mappings (root + 5 file clusters)");
|
||||
|
||||
// Verify root directory cluster (2) is mapped
|
||||
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(2), "Root directory cluster should be mapped");
|
||||
|
||||
// Verify FILE1 clusters (3 and 4) have the FILE1 timestamp
|
||||
var file1ExpectedTime = new DateTime(2023, 6, 15, 14, 30, 20, DateTimeKind.Utc);
|
||||
Assert.AreEqual(file1ExpectedTime, walker.ClusterToTimestampMap[3], "Cluster 3 should have FILE1 timestamp");
|
||||
Assert.AreEqual(file1ExpectedTime, walker.ClusterToTimestampMap[4], "Cluster 4 should have FILE1 timestamp");
|
||||
|
||||
// Verify FILE2 clusters (5, 6, 7) have the FILE2 timestamp
|
||||
var file2ExpectedTime = new DateTime(2023, 6, 20, 16, 45, 30, DateTimeKind.Utc);
|
||||
Assert.AreEqual(file2ExpectedTime, walker.ClusterToTimestampMap[5], "Cluster 5 should have FILE2 timestamp");
|
||||
Assert.AreEqual(file2ExpectedTime, walker.ClusterToTimestampMap[6], "Cluster 6 should have FILE2 timestamp");
|
||||
Assert.AreEqual(file2ExpectedTime, walker.ClusterToTimestampMap[7], "Cluster 7 should have FILE2 timestamp");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32DirectoryWalker_SubdirectoryRecursion()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create a FAT with cluster chains for root and subdirectory
|
||||
var fatData = CreateFatData(
|
||||
0x00000000, // Cluster 0: reserved
|
||||
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
|
||||
0x0FFFFFFF, // Cluster 2: root directory (EOC)
|
||||
0x0FFFFFFF, // Cluster 3: subdirectory (EOC)
|
||||
0x00000005, // Cluster 4: file in subdirectory points to cluster 5
|
||||
0x0FFFFFFF, // Cluster 5: EOC (file data)
|
||||
0x0FFFFFFF // Cluster 6: another file in subdirectory (EOC)
|
||||
);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
// Create root directory with a subdirectory entry
|
||||
var subdirEntry = CreateDirectoryEntry(
|
||||
"SUBDIR ",
|
||||
0x10, // Directory attribute
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 10),
|
||||
(ushort)((9 << 11) | (0 << 5) | 0),
|
||||
0,
|
||||
3, // Points to cluster 3
|
||||
0);
|
||||
|
||||
var rootClusterData = subdirEntry.Concat(new byte[4096 - 32]).ToArray();
|
||||
|
||||
// Create subdirectory cluster with two file entries
|
||||
var subdirFile1Entry = CreateDirectoryEntry(
|
||||
"SUBFILE1TXT",
|
||||
0x00, // Regular file
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 15),
|
||||
(ushort)((14 << 11) | (30 << 5) | 10),
|
||||
0,
|
||||
4, // Points to cluster 4 (chain: 4 -> 5)
|
||||
8192);
|
||||
|
||||
var subdirFile2Entry = CreateDirectoryEntry(
|
||||
"SUBFILE2DAT",
|
||||
0x00, // Regular file
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
|
||||
(ushort)((16 << 11) | (45 << 5) | (30 / 2)),
|
||||
0,
|
||||
6, // Points to cluster 6
|
||||
4096);
|
||||
|
||||
var subdirClusterData = subdirFile1Entry.Concat(subdirFile2Entry).Concat(new byte[4096 - 64]).ToArray();
|
||||
|
||||
var clusterData = new Dictionary<uint, byte[]>
|
||||
{
|
||||
[2] = rootClusterData,
|
||||
[3] = subdirClusterData
|
||||
};
|
||||
|
||||
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
|
||||
|
||||
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
|
||||
|
||||
// Should have mappings for:
|
||||
// - Root directory cluster (2)
|
||||
// - Subdirectory cluster (3) with its own timestamp
|
||||
// - File1 clusters (4, 5) with their timestamp
|
||||
// - File2 cluster (6) with its timestamp
|
||||
Assert.AreEqual(5, walker.ClusterToTimestampMap.Count, "Should have 5 cluster mappings (root + subdir + file clusters)");
|
||||
|
||||
// Verify root directory cluster is mapped
|
||||
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(2), "Root directory cluster should be mapped");
|
||||
|
||||
// Verify subdirectory cluster has the subdirectory timestamp
|
||||
var subdirExpectedTime = new DateTime(2023, 6, 10, 9, 0, 0, DateTimeKind.Utc);
|
||||
Assert.AreEqual(subdirExpectedTime, walker.ClusterToTimestampMap[3], "Subdirectory cluster should have subdirectory timestamp");
|
||||
|
||||
// Verify subfile1 clusters have their timestamp
|
||||
var file1ExpectedTime = new DateTime(2023, 6, 15, 14, 30, 20, DateTimeKind.Utc);
|
||||
Assert.AreEqual(file1ExpectedTime, walker.ClusterToTimestampMap[4], "Subfile1 cluster 4 should have file timestamp");
|
||||
Assert.AreEqual(file1ExpectedTime, walker.ClusterToTimestampMap[5], "Subfile1 cluster 5 should have file timestamp");
|
||||
|
||||
// Verify subfile2 cluster has its timestamp
|
||||
var file2ExpectedTime = new DateTime(2023, 6, 20, 16, 45, 30, DateTimeKind.Utc);
|
||||
Assert.AreEqual(file2ExpectedTime, walker.ClusterToTimestampMap[6], "Subfile2 cluster should have file timestamp");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32DirectoryWalker_OverlappingClusters_UsesMostRecentTimestamp()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create a FAT where cluster 3 is referenced by both files
|
||||
var fatData = CreateFatData(
|
||||
0x00000000, // Cluster 0: reserved
|
||||
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
|
||||
0x0FFFFFFF, // Cluster 2: root directory (EOC)
|
||||
0x0FFFFFFF, // Cluster 3: EOC (shared by both files - unusual but testable)
|
||||
0x0FFFFFFF, // Cluster 4: EOC
|
||||
0x0FFFFFFF // Cluster 5: EOC
|
||||
);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
// Create root directory with two file entries referencing the same cluster
|
||||
// This is unusual in practice but tests the "most recent timestamp" logic
|
||||
var olderFileEntry = CreateDirectoryEntry(
|
||||
"OLDER TXT",
|
||||
0x00, // Regular file
|
||||
(ushort)(((2020 - 1980) << 9) | (1 << 5) | 1),
|
||||
(ushort)((0 << 11) | (0 << 5) | 0),
|
||||
0,
|
||||
3, // Points to cluster 3
|
||||
4096);
|
||||
|
||||
var newerFileEntry = CreateDirectoryEntry(
|
||||
"NEWER TXT",
|
||||
0x00, // Regular file
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
|
||||
(ushort)((16 << 11) | (45 << 5) | (30 / 2)),
|
||||
0,
|
||||
3, // Also points to cluster 3
|
||||
4096);
|
||||
|
||||
var clusterData = new Dictionary<uint, byte[]>
|
||||
{
|
||||
[2] = [.. olderFileEntry, .. newerFileEntry, .. new byte[4096 - 64]]
|
||||
};
|
||||
|
||||
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
|
||||
|
||||
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
|
||||
|
||||
// Cluster 3 should have the newer timestamp
|
||||
var newerExpectedTime = new DateTime(2023, 6, 20, 16, 45, 30, DateTimeKind.Utc);
|
||||
Assert.AreEqual(newerExpectedTime, walker.ClusterToTimestampMap[3], "Shared cluster should have most recent timestamp");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32DirectoryWalker_SkipVolumeLabel()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create a FAT where cluster 2 (root) points to EOC, with enough entries for clusters 3 and 4
|
||||
var fatData = CreateFatData(
|
||||
0x00000000, // Cluster 0: reserved
|
||||
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
|
||||
0x0FFFFFFF, // Cluster 2: root directory (EOC)
|
||||
0x0FFFFFFF, // Cluster 3: volume label would point here (but should be skipped)
|
||||
0x0FFFFFFF // Cluster 4: valid entry (EOC)
|
||||
);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
// Create root directory cluster data with a volume label followed by a valid file entry
|
||||
var volumeLabelEntry = CreateDirectoryEntry(
|
||||
"VOLUMELABEL",
|
||||
0x08, // Volume label attribute
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 15),
|
||||
(ushort)((14 << 11) | (30 << 5) | 10),
|
||||
0,
|
||||
3, // Would point to cluster 3
|
||||
0);
|
||||
|
||||
var validEntry = CreateDirectoryEntry(
|
||||
"VALID FIL",
|
||||
0x00, // Regular file
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
|
||||
(ushort)((10 << 11) | (0 << 5) | 0),
|
||||
0,
|
||||
4, // Points to cluster 4
|
||||
200);
|
||||
|
||||
var clusterData = new Dictionary<uint, byte[]>
|
||||
{
|
||||
[2] = [.. volumeLabelEntry, .. validEntry, .. new byte[4096 - 64]]
|
||||
};
|
||||
|
||||
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
|
||||
|
||||
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
|
||||
|
||||
// Should only have cluster 4 mapped (volume label should be skipped)
|
||||
Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(3), "Volume label's cluster should not be mapped");
|
||||
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(4), "Valid entry's cluster should be mapped");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32DirectoryWalker_InvalidClusterNumber_Skipped()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create a FAT where cluster 2 (root) points to EOC, with enough entries for cluster 3
|
||||
var fatData = CreateFatData(
|
||||
0x00000000, // Cluster 0: reserved
|
||||
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
|
||||
0x0FFFFFFF, // Cluster 2: root directory (EOC)
|
||||
0x0FFFFFFF // Cluster 3: valid entry (EOC) - for the valid file
|
||||
);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
// Create root directory cluster data with an entry that has cluster 0 (invalid)
|
||||
var invalidClusterEntry = CreateDirectoryEntry(
|
||||
"INVALID TXT",
|
||||
0x00, // Regular file
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 15),
|
||||
(ushort)((14 << 11) | (30 << 5) | 10),
|
||||
0,
|
||||
0, // Cluster 0 is invalid (reserved)
|
||||
100);
|
||||
|
||||
var validEntry = CreateDirectoryEntry(
|
||||
"VALID FIL",
|
||||
0x00, // Regular file
|
||||
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
|
||||
(ushort)((10 << 11) | (0 << 5) | 0),
|
||||
0,
|
||||
3, // Points to cluster 3
|
||||
200);
|
||||
|
||||
var clusterData = new Dictionary<uint, byte[]>
|
||||
{
|
||||
[2] = [.. invalidClusterEntry, .. validEntry, .. new byte[4096 - 64]]
|
||||
};
|
||||
|
||||
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
|
||||
|
||||
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
|
||||
|
||||
// Should only have cluster 3 mapped (invalid cluster 0 should be skipped)
|
||||
Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(0), "Invalid cluster 0 should not be mapped");
|
||||
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(3), "Valid entry's cluster should be mapped");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Legacy;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.Fat32;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="Fat32Filesystem"/> class and related types.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[Category("DiskImageUnit")]
|
||||
public class Fat32FilesystemTests
|
||||
{
|
||||
[Test]
|
||||
public void Test_Fat32FilesystemMetadata_Properties()
|
||||
{
|
||||
var metadata = new Fat32FilesystemMetadata
|
||||
{
|
||||
BlockSize = 1024 * 1024,
|
||||
ClusterSize = 4096,
|
||||
TotalClusters = 1000,
|
||||
AllocatedClusters = 500,
|
||||
FreeClusters = 500
|
||||
};
|
||||
|
||||
Assert.AreEqual(1024 * 1024, metadata.BlockSize);
|
||||
Assert.AreEqual(4096, metadata.ClusterSize);
|
||||
Assert.AreEqual(1000, metadata.TotalClusters);
|
||||
Assert.AreEqual(500, metadata.AllocatedClusters);
|
||||
Assert.AreEqual(500, metadata.FreeClusters);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32FilesystemMetadata_RecordEquality()
|
||||
{
|
||||
var metadata1 = new Fat32FilesystemMetadata
|
||||
{
|
||||
BlockSize = 1024 * 1024,
|
||||
ClusterSize = 4096,
|
||||
TotalClusters = 1000,
|
||||
AllocatedClusters = 500,
|
||||
FreeClusters = 500
|
||||
};
|
||||
|
||||
var metadata2 = new Fat32FilesystemMetadata
|
||||
{
|
||||
BlockSize = 1024 * 1024,
|
||||
ClusterSize = 4096,
|
||||
TotalClusters = 1000,
|
||||
AllocatedClusters = 500,
|
||||
FreeClusters = 500
|
||||
};
|
||||
|
||||
var metadata3 = new Fat32FilesystemMetadata
|
||||
{
|
||||
BlockSize = 512 * 1024, // Different
|
||||
ClusterSize = 4096,
|
||||
TotalClusters = 1000,
|
||||
AllocatedClusters = 500,
|
||||
FreeClusters = 500
|
||||
};
|
||||
|
||||
Assert.AreEqual(metadata1, metadata2);
|
||||
Assert.AreNotEqual(metadata1, metadata3);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32File_DefaultValues()
|
||||
{
|
||||
var file = new Fat32File();
|
||||
|
||||
Assert.IsNull(file.Path);
|
||||
Assert.IsNull(file.Address);
|
||||
Assert.AreEqual(0, file.Size);
|
||||
Assert.IsFalse(file.IsDirectory);
|
||||
Assert.IsNull(file.LastModified);
|
||||
Assert.IsFalse(file.IsAllocated);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32File_WithValues()
|
||||
{
|
||||
var timestamp = new DateTime(2023, 6, 15, 10, 30, 0, DateTimeKind.Utc);
|
||||
var file = new Fat32File
|
||||
{
|
||||
Path = "test_block",
|
||||
Address = 1024,
|
||||
Size = 1024 * 1024,
|
||||
LastModified = timestamp,
|
||||
IsAllocated = true
|
||||
};
|
||||
|
||||
Assert.AreEqual("test_block", file.Path);
|
||||
Assert.AreEqual(1024, file.Address);
|
||||
Assert.AreEqual(1024 * 1024, file.Size);
|
||||
Assert.IsFalse(file.IsDirectory);
|
||||
Assert.AreEqual(timestamp, file.LastModified);
|
||||
Assert.IsTrue(file.IsAllocated);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32File_ImplementsIFile()
|
||||
{
|
||||
var file = new Fat32File();
|
||||
Assert.IsInstanceOf<IFile>(file);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32File_LastModified_Nullable()
|
||||
{
|
||||
var file1 = new Fat32File { LastModified = DateTime.UtcNow };
|
||||
var file2 = new Fat32File { LastModified = null };
|
||||
|
||||
Assert.IsTrue(file1.LastModified.HasValue);
|
||||
Assert.IsFalse(file2.LastModified.HasValue);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32File_IsAllocated_FalseByDefault()
|
||||
{
|
||||
var file = new Fat32File();
|
||||
Assert.IsFalse(file.IsAllocated);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32File_IsAllocated_CanBeTrue()
|
||||
{
|
||||
var file = new Fat32File { IsAllocated = true };
|
||||
Assert.IsTrue(file.IsAllocated);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32File_Address_LongNullable()
|
||||
{
|
||||
var file1 = new Fat32File { Address = 0 };
|
||||
var file2 = new Fat32File { Address = long.MaxValue };
|
||||
var file3 = new Fat32File { Address = null };
|
||||
|
||||
Assert.AreEqual(0, file1.Address);
|
||||
Assert.AreEqual(long.MaxValue, file2.Address);
|
||||
Assert.IsNull(file3.Address);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32File_Size_Long()
|
||||
{
|
||||
var file1 = new Fat32File { Size = 0 };
|
||||
var file2 = new Fat32File { Size = 1024 * 1024 };
|
||||
var file3 = new Fat32File { Size = long.MaxValue };
|
||||
|
||||
Assert.AreEqual(0, file1.Size);
|
||||
Assert.AreEqual(1024 * 1024, file2.Size);
|
||||
Assert.AreEqual(long.MaxValue, file3.Size);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32File_Path_CanBeNull()
|
||||
{
|
||||
var file1 = new Fat32File { Path = null };
|
||||
var file2 = new Fat32File { Path = "test/path" };
|
||||
|
||||
Assert.IsNull(file1.Path);
|
||||
Assert.AreEqual("test/path", file2.Path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Legacy;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.Fat32;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="Fat32Table"/> FAT table reader.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[Category("DiskImageUnit")]
|
||||
public class Fat32TableTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a valid FAT32 boot sector with known values for testing.
|
||||
/// </summary>
|
||||
private static byte[] CreateValidBootSector()
|
||||
{
|
||||
var bootSector = new byte[512];
|
||||
|
||||
// Bytes per sector at offset 0x0B (512 = 0x0200)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0B, 2), 512);
|
||||
|
||||
// Sectors per cluster at offset 0x0D (8 sectors)
|
||||
bootSector[0x0D] = 8;
|
||||
|
||||
// Reserved sector count at offset 0x0E (32 sectors)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0E, 2), 32);
|
||||
|
||||
// Number of FATs at offset 0x10 (2 FATs)
|
||||
bootSector[0x10] = 2;
|
||||
|
||||
// Total sectors 32 at offset 0x20 (1,000,000 sectors = ~512MB)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x20, 4), 1_000_000);
|
||||
|
||||
// FAT size 32 at offset 0x24 (244 sectors per FAT)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x24, 4), 244);
|
||||
|
||||
// Root cluster at offset 0x2C (cluster 2)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x2C, 4), 2);
|
||||
|
||||
// FSInfo sector at offset 0x30 (sector 1)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x30, 2), 1);
|
||||
|
||||
// Filesystem type string at offset 0x52 (8 bytes: "FAT32 ")
|
||||
var fsType = System.Text.Encoding.ASCII.GetBytes("FAT32 ");
|
||||
fsType.CopyTo(bootSector, 0x52);
|
||||
|
||||
// Boot sector signature at offset 510: bytes 0x55, 0xAA on disk (0xAA55 as little-endian uint16)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(510, 2), 0xAA55);
|
||||
|
||||
return bootSector;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates FAT data with specified entries.
|
||||
/// </summary>
|
||||
private static byte[] CreateFatData(params uint[] entries)
|
||||
{
|
||||
var fatData = new byte[entries.Length * 4];
|
||||
for (int i = 0; i < entries.Length; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(fatData.AsSpan(i * 4, 4), entries[i]);
|
||||
}
|
||||
return fatData;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32Table_FreeCluster_NotAllocated()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create FAT with cluster 2 as free
|
||||
var fatData = CreateFatData(0, 0, Fat32Table.FREE_CLUSTER);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
Assert.IsFalse(fatTable.IsClusterAllocated(2), "Free cluster should not be allocated");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32Table_AllocatedCluster_IsAllocated()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create FAT with cluster 2 pointing to cluster 3 (allocated)
|
||||
var fatData = CreateFatData(0, 0, 3, Fat32Table.EOC_MIN);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
Assert.IsTrue(fatTable.IsClusterAllocated(2), "Cluster with valid next-cluster value should be allocated");
|
||||
Assert.IsTrue(fatTable.IsClusterAllocated(3), "Cluster with EOC should be allocated");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32Table_EndOfChain_IsAllocated()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Test all EOC values (0x0FFFFFF8 - 0x0FFFFFFF)
|
||||
for (uint eocValue = Fat32Table.EOC_MIN; eocValue <= Fat32Table.EOC_MAX; eocValue++)
|
||||
{
|
||||
var fatData = CreateFatData(0, 0, eocValue);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
Assert.IsTrue(fatTable.IsClusterAllocated(2), $"Cluster with EOC value 0x{eocValue:X8} should be allocated");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32Table_BadCluster_NotAllocated()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create FAT with cluster 2 as bad
|
||||
var fatData = CreateFatData(0, 0, Fat32Table.BAD_CLUSTER);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
Assert.IsFalse(fatTable.IsClusterAllocated(2), "Bad cluster should not be allocated");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32Table_GetClusterChain_SingleCluster()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create FAT with cluster 2 having EOC immediately
|
||||
var fatData = CreateFatData(0, 0, Fat32Table.EOC_MIN);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
var chain = fatTable.GetClusterChain(2);
|
||||
|
||||
Assert.AreEqual(1, chain.Count, "Single cluster chain should have 1 entry");
|
||||
Assert.AreEqual(2u, chain[0], "Chain should contain the start cluster");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32Table_GetClusterChain_MultipleClusters()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create FAT with chain: 2 -> 3 -> 4 -> EOC
|
||||
var fatData = CreateFatData(0, 0, 3, 4, Fat32Table.EOC_MIN);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
var chain = fatTable.GetClusterChain(2);
|
||||
|
||||
Assert.AreEqual(3, chain.Count, "Chain should have 3 entries");
|
||||
Assert.AreEqual(2u, chain[0], "First entry should be cluster 2");
|
||||
Assert.AreEqual(3u, chain[1], "Second entry should be cluster 3");
|
||||
Assert.AreEqual(4u, chain[2], "Third entry should be cluster 4");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32Table_GetClusterChain_CircularDetection()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create FAT with circular chain: 2 -> 3 -> 2 (circular)
|
||||
var fatData = CreateFatData(0, 0, 3, 2);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => fatTable.GetClusterChain(2));
|
||||
StringAssert.Contains("Circular", ex!.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32Table_AllocationBitmap_MatchesFatEntries()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create FAT with mixed entries:
|
||||
// 0: reserved (typically EOC or 0)
|
||||
// 1: reserved (typically EOC or 0)
|
||||
// 2: free
|
||||
// 3: allocated (points to 4)
|
||||
// 4: EOC (end of chain)
|
||||
// 5: bad cluster
|
||||
var fatData = CreateFatData(
|
||||
Fat32Table.FREE_CLUSTER, // cluster 0
|
||||
Fat32Table.FREE_CLUSTER, // cluster 1
|
||||
Fat32Table.FREE_CLUSTER, // cluster 2 (free)
|
||||
4, // cluster 3 (allocated, points to 4)
|
||||
Fat32Table.EOC_MIN, // cluster 4 (end of chain)
|
||||
Fat32Table.BAD_CLUSTER // cluster 5 (bad)
|
||||
);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
// Reserved clusters 0 and 1 - treated as free (not allocated)
|
||||
Assert.IsFalse(fatTable.IsClusterAllocated(0), "Cluster 0 should not be allocated");
|
||||
Assert.IsFalse(fatTable.IsClusterAllocated(1), "Cluster 1 should not be allocated");
|
||||
|
||||
// Cluster 2 is free
|
||||
Assert.IsFalse(fatTable.IsClusterAllocated(2), "Cluster 2 (free) should not be allocated");
|
||||
|
||||
// Cluster 3 is allocated (points to next cluster)
|
||||
Assert.IsTrue(fatTable.IsClusterAllocated(3), "Cluster 3 (allocated) should be allocated");
|
||||
|
||||
// Cluster 4 is EOC
|
||||
Assert.IsTrue(fatTable.IsClusterAllocated(4), "Cluster 4 (EOC) should be allocated");
|
||||
|
||||
// Cluster 5 is bad
|
||||
Assert.IsFalse(fatTable.IsClusterAllocated(5), "Cluster 5 (bad) should not be allocated");
|
||||
|
||||
// Verify counts
|
||||
Assert.AreEqual(6u, fatTable.TotalClusters, "Total clusters should be 6");
|
||||
Assert.AreEqual(2u, fatTable.AllocatedClusters, "Allocated clusters should be 2 (clusters 3 and 4)");
|
||||
Assert.AreEqual(4u, fatTable.FreeClusters, "Free clusters should be 4 (clusters 0, 1, 2, and 5 is counted as free since it's bad)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32Table_EntryMask_IgnoresUpperBits()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create FAT with cluster 2 having upper 4 bits set (should be masked)
|
||||
// 0xF0000003 with mask 0x0FFFFFFF = 0x00000003 (points to cluster 3)
|
||||
uint entryWithUpperBits = 0xF0000003;
|
||||
var fatData = CreateFatData(0, 0, entryWithUpperBits, Fat32Table.EOC_MIN);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
var nextCluster = fatTable.GetNextCluster(2);
|
||||
|
||||
Assert.AreEqual(3u, nextCluster, "Upper 4 bits should be masked, leaving value 3");
|
||||
Assert.IsTrue(fatTable.IsClusterAllocated(2), "Cluster should be allocated even with upper bits set");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32Table_GetNextCluster_ReturnsMaskedValue()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create FAT where cluster 2 points to cluster 5 (0x00000005)
|
||||
var fatData = CreateFatData(0, 0, 5, Fat32Table.EOC_MIN, Fat32Table.EOC_MIN, Fat32Table.EOC_MIN);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
var nextCluster = fatTable.GetNextCluster(2);
|
||||
|
||||
Assert.AreEqual(5u, nextCluster, "GetNextCluster should return the next cluster number");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32Table_ClusterOutOfRange_Throws()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
var fatData = CreateFatData(0, 0, Fat32Table.EOC_MIN);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
// Try to access cluster beyond the FAT size
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => fatTable.IsClusterAllocated(100));
|
||||
StringAssert.Contains("out of range", ex!.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32Table_GetClusterChain_StartClusterOutOfRange_Throws()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
var fatData = CreateFatData(0, 0, Fat32Table.EOC_MIN);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => fatTable.GetClusterChain(100));
|
||||
StringAssert.Contains("out of range", ex!.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32Table_GetClusterChain_InvalidClusterInChain_Throws()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create FAT where cluster 2 points to cluster 3 which is free (invalid in a chain)
|
||||
var fatData = CreateFatData(0, 0, 3, Fat32Table.FREE_CLUSTER);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => fatTable.GetClusterChain(2));
|
||||
StringAssert.Contains("Invalid cluster", ex!.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32Table_Constructor_NullFatData_Throws()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
var ex = Assert.Throws<ArgumentNullException>(() => new Fat32Table(bootSector, null!));
|
||||
Assert.AreEqual("fatData", ex!.ParamName);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32Table_EmptyClusterChain()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create FAT with cluster 0 having EOC
|
||||
var fatData = CreateFatData(Fat32Table.EOC_MIN);
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
var chain = fatTable.GetClusterChain(0);
|
||||
|
||||
Assert.AreEqual(1, chain.Count, "Single cluster chain should have 1 entry");
|
||||
Assert.AreEqual(0u, chain[0], "Chain should contain cluster 0");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32Table_LongClusterChain()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Create a chain of 10 clusters: 2 -> 3 -> 4 -> ... -> 11 -> EOC
|
||||
var entries = new List<uint> { 0, 0 }; // clusters 0 and 1
|
||||
for (uint i = 2; i <= 10; i++)
|
||||
{
|
||||
entries.Add(i + 1); // each cluster points to the next
|
||||
}
|
||||
entries.Add(Fat32Table.EOC_MIN); // cluster 11 is EOC
|
||||
|
||||
var fatData = CreateFatData(entries.ToArray());
|
||||
var fatTable = new Fat32Table(bootSector, fatData);
|
||||
|
||||
var chain = fatTable.GetClusterChain(2);
|
||||
|
||||
Assert.AreEqual(10, chain.Count, "Chain should have 10 entries");
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
Assert.AreEqual((uint)(i + 2), chain[i], $"Chain entry {i} should be cluster {i + 2}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Legacy;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.Fat32;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="Fat32Filesystem.Fat32ZeroStream"/> class.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[Category("DiskImageUnit")]
|
||||
public class Fat32ZeroStreamTests
|
||||
{
|
||||
[Test]
|
||||
public void Test_Fat32ZeroStream_Read_ReturnsZeros()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize);
|
||||
|
||||
var buffer = new byte[1024];
|
||||
int bytesRead = stream.Read(buffer, 0, buffer.Length);
|
||||
|
||||
Assert.AreEqual(1024, bytesRead, "Should read requested number of bytes");
|
||||
|
||||
// Verify all bytes are zero
|
||||
for (int i = 0; i < buffer.Length; i++)
|
||||
{
|
||||
Assert.AreEqual(0, buffer[i], $"Byte at position {i} should be zero");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Test_Fat32ZeroStream_ReadAsync_ReturnsZeros()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize);
|
||||
|
||||
var buffer = new byte[1024];
|
||||
int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
|
||||
|
||||
Assert.AreEqual(1024, bytesRead, "Should read requested number of bytes");
|
||||
|
||||
// Verify all bytes are zero
|
||||
for (int i = 0; i < buffer.Length; i++)
|
||||
{
|
||||
Assert.AreEqual(0, buffer[i], $"Byte at position {i} should be zero");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32ZeroStream_Length_MatchesBlockSize()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize);
|
||||
|
||||
Assert.AreEqual(blockSize, stream.Length, "Stream length should match block size");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32ZeroStream_Seek_Works()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize);
|
||||
|
||||
// Seek to beginning
|
||||
var position = stream.Seek(0, SeekOrigin.Begin);
|
||||
Assert.AreEqual(0, position, "Should be at position 0");
|
||||
|
||||
// Seek to middle
|
||||
position = stream.Seek(blockSize / 2, SeekOrigin.Begin);
|
||||
Assert.AreEqual(blockSize / 2, position, "Should be at middle position");
|
||||
|
||||
// Seek relative to current
|
||||
position = stream.Seek(1024, SeekOrigin.Current);
|
||||
Assert.AreEqual(blockSize / 2 + 1024, position, "Should be at relative position");
|
||||
|
||||
// Seek from end
|
||||
position = stream.Seek(-1024, SeekOrigin.End);
|
||||
Assert.AreEqual(blockSize - 1024, position, "Should be at position from end");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32ZeroStream_Write_ThrowsNotSupported()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize);
|
||||
|
||||
var buffer = new byte[1024];
|
||||
Assert.Throws<NotSupportedException>(() => stream.Write(buffer, 0, buffer.Length));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32ZeroStream_MultipleInstances_ShareBuffer()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
|
||||
// Create two streams with the same block size
|
||||
using var stream1 = new Fat32Filesystem.Fat32ZeroStream(blockSize);
|
||||
using var stream2 = new Fat32Filesystem.Fat32ZeroStream(blockSize);
|
||||
|
||||
// Both should read zeros
|
||||
var buffer1 = new byte[1024];
|
||||
var buffer2 = new byte[1024];
|
||||
|
||||
stream1.ReadExactly(buffer1);
|
||||
stream2.ReadExactly(buffer2);
|
||||
|
||||
// Verify both have zeros
|
||||
for (int i = 0; i < buffer1.Length; i++)
|
||||
{
|
||||
Assert.AreEqual(0, buffer1[i], $"Buffer1 byte at position {i} should be zero");
|
||||
Assert.AreEqual(0, buffer2[i], $"Buffer2 byte at position {i} should be zero");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32ZeroStream_ReadPartial_ReturnsCorrectCount()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize);
|
||||
|
||||
// Seek to near the end
|
||||
stream.Seek(blockSize - 100, SeekOrigin.Begin);
|
||||
|
||||
var buffer = new byte[1024];
|
||||
int bytesRead = stream.Read(buffer, 0, buffer.Length);
|
||||
|
||||
Assert.AreEqual(100, bytesRead, "Should only read remaining bytes");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32ZeroStream_ReadPastEnd_ReturnsZero()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize);
|
||||
|
||||
// Seek to end
|
||||
stream.Seek(0, SeekOrigin.End);
|
||||
|
||||
var buffer = new byte[1024];
|
||||
int bytesRead = stream.Read(buffer, 0, buffer.Length);
|
||||
|
||||
Assert.AreEqual(0, bytesRead, "Should return 0 when reading past end");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32ZeroStream_CanRead_CanWrite_CanSeek()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize);
|
||||
|
||||
Assert.IsTrue(stream.CanRead, "CanRead should be true");
|
||||
Assert.IsFalse(stream.CanWrite, "CanWrite should be false");
|
||||
Assert.IsTrue(stream.CanSeek, "CanSeek should be true");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32ZeroStream_InvalidBlockSize_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Fat32Filesystem.Fat32ZeroStream(0));
|
||||
Assert.Throws<ArgumentException>(() => new Fat32Filesystem.Fat32ZeroStream(-1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32ZeroStream_SetLength_ThrowsNotSupported()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize);
|
||||
|
||||
Assert.Throws<NotSupportedException>(() => stream.SetLength(1024));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32ZeroStream_Flush_DoesNotThrow()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize);
|
||||
|
||||
Assert.DoesNotThrow(() => stream.Flush());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32ZeroStream_Position_Property()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize);
|
||||
|
||||
Assert.AreEqual(0, stream.Position, "Initial position should be 0");
|
||||
|
||||
stream.Position = 1024;
|
||||
Assert.AreEqual(1024, stream.Position, "Position should be settable");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32ZeroStream_Position_InvalidValue_Throws()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize);
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => stream.Position = -1);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => stream.Position = blockSize + 1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32ZeroStream_Seek_InvalidOrigin_Throws()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize);
|
||||
|
||||
Assert.Throws<ArgumentException>(() => stream.Seek(0, (SeekOrigin)999));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32ZeroStream_Seek_OutOfRange_Throws()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize);
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => stream.Seek(-1, SeekOrigin.Begin));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => stream.Seek(blockSize + 1, SeekOrigin.Begin));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32ZeroStream_Read_NullBuffer_Throws()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize);
|
||||
|
||||
#pragma warning disable CS8600, CS8625
|
||||
Assert.Throws<ArgumentNullException>(() => stream.ReadExactly(null, 0, 1024));
|
||||
#pragma warning restore CS8600, CS8625
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32ZeroStream_Read_InvalidOffset_Throws()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize);
|
||||
|
||||
var buffer = new byte[1024];
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => stream.ReadExactly(buffer, -1, 1024));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => stream.ReadExactly(buffer, buffer.Length + 1, 1024));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Fat32ZeroStream_Read_InvalidCount_Throws()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize);
|
||||
|
||||
var buffer = new byte[1024];
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => stream.ReadExactly(buffer, 0, -1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => stream.ReadExactly(buffer, 512, 1024)); // Exceeds buffer length
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// 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.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Factory class for creating the appropriate <see cref="IDiskImageHelper"/> implementation
|
||||
/// based on the host operating system.
|
||||
/// </summary>
|
||||
public static class DiskImageHelperFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates and returns the appropriate disk image helper for the current operating system.
|
||||
/// </summary>
|
||||
/// <returns>An <see cref="IDiskImageHelper"/> implementation for the current OS.</returns>
|
||||
/// <exception cref="PlatformNotSupportedException">Thrown when the current OS is not supported.</exception>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether disk image operations are supported on the current platform.
|
||||
/// </summary>
|
||||
/// <value><c>true</c> if disk image operations are supported; otherwise, <c>false</c>.</value>
|
||||
public static bool IsSupported
|
||||
{
|
||||
get
|
||||
{
|
||||
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
|| RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
|
||||
|| RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the current platform.
|
||||
/// </summary>
|
||||
/// <value>The platform name ("Windows", "Linux", "macOS", or "Unknown").</value>
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// 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.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Helper methods for disk image unit tests.
|
||||
/// Provides common operations for creating, initializing, and managing disk images.
|
||||
/// </summary>
|
||||
internal static class DiskImageTestHelpers
|
||||
{
|
||||
private const long MiB = 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a raw disk interface for the specified disk identifier based on the current platform.
|
||||
/// </summary>
|
||||
/// <param name="diskIdentifier">The disk identifier (e.g., \\.\PhysicalDriveN on Windows, /dev/loopN on Linux).</param>
|
||||
/// <returns>An initialized IRawDisk instance for the specified platform.</returns>
|
||||
/// <exception cref="PlatformNotSupportedException">Thrown when the current OS is not supported.</exception>
|
||||
internal static IRawDisk CreateRawDiskForIdentifier(string diskIdentifier)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
return new Proprietary.DiskImage.Disk.Windows(diskIdentifier);
|
||||
else if (OperatingSystem.IsLinux())
|
||||
return new Linux(diskIdentifier);
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
return new Mac(diskIdentifier);
|
||||
else
|
||||
throw new PlatformNotSupportedException("Unsupported operating system.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a disk image file with the specified parameters.
|
||||
/// </summary>
|
||||
/// <param name="diskHelper">The disk image helper.</param>
|
||||
/// <param name="imagePath">The path where the disk image will be created.</param>
|
||||
/// <param name="size">The size of the disk in bytes.</param>
|
||||
/// <param name="tableType">The partition table type (GPT, MBR, etc.).</param>
|
||||
/// <param name="partitions">Array of tuples specifying filesystem type and size for each partition.</param>
|
||||
/// <returns>The disk identifier for the created disk.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills a partition with well-known test data (repeating pattern of 0x00-0xFF).
|
||||
/// </summary>
|
||||
/// <param name="rawDisk">The raw disk interface.</param>
|
||||
/// <param name="partitionOffset">The starting offset of the partition in bytes.</param>
|
||||
/// <param name="partitionSize">The size of the partition in bytes.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the file extension for disk images based on the current platform.
|
||||
/// </summary>
|
||||
/// <returns>The platform-specific disk image extension (vhdx, img, or dmg).</returns>
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a file if it exists, catching any exceptions.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The path to the file to delete.</param>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// 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 Duplicati.Proprietary.DiskImage;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for disk image helper operations that work across different operating systems.
|
||||
/// Provides methods for creating, attaching, formatting, and managing virtual disk images
|
||||
/// for testing disk image backup and restore operations.
|
||||
/// </summary>
|
||||
public interface IDiskImageHelper : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a virtual disk file, attaches it to the system, and returns the physical drive path.
|
||||
/// </summary>
|
||||
/// <param name="imagePath">The path where the disk image file will be created.</param>
|
||||
/// <param name="sizeB">The size of the disk image in bytes.</param>
|
||||
/// <returns>The physical drive path (e.g., \\.\PhysicalDriveN on Windows, /dev/loopN on Linux).</returns>
|
||||
string CreateDisk(string imagePath, long sizeB);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes, partitions, and formats a disk based on the provided parameters
|
||||
/// </summary>
|
||||
/// <param name="diskIdentifier">The identifier of the disk to initialize.</param>
|
||||
/// <param name="tableType">The type of partition table to create (e.g., GPT, MBR).</param>
|
||||
/// <param name="partitions">An array of tuples specifying the file system type and size for each partition. If the size is 0, the partition will use the remaining available space.</param>
|
||||
/// <returns>An array of mount points for the created partitions.</returns>
|
||||
string[] InitializeDisk(string diskIdentifier, PartitionTableType tableType, (FileSystemType, long)[] partitions);
|
||||
|
||||
/// <summary>
|
||||
/// Mounts all of the partitions on the specified disk and returns their mount points.
|
||||
/// </summary>
|
||||
/// <param name="diskIdentifier">The identifier of the disk to mount.</param>
|
||||
/// <param name="baseMountPath">Optional base path for mounting the partitions.</param>
|
||||
/// <param name="readOnly">Indicates whether the partitions should be mounted as read-only.</param>
|
||||
/// <param name="fileSystemTypes">Optional array of file system types for the partitions.</param>
|
||||
/// <returns>An array of mount points for the mounted partitions.</returns>
|
||||
string[] Mount(string diskIdentifier, string? baseMountPath = null, bool readOnly = false, FileSystemType[]? fileSystemTypes = null);
|
||||
|
||||
/// <summary>
|
||||
/// Unmounts all of the partitions on the specified disk, ensuring they are safely detached from the system. The disk will still be attached, but will be pulled offline. After this call, the disk device can be written to.
|
||||
/// </summary>
|
||||
/// <param name="diskIdentifier">The identifier of the disk to unmount.</param>
|
||||
void Unmount(string diskIdentifier);
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up and deletes a disk image file, ensuring it is detached first.
|
||||
/// </summary>
|
||||
/// <param name="imagePath">The path to the disk image file.</param>
|
||||
/// <param name="diskIdentifier">The identifier of the disk to clean up. If null, the method will attempt to determine the disk based on the image path.</param>
|
||||
void CleanupDisk(string imagePath, string? diskIdentifier = null);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the current process has the necessary privileges to perform disk operations.
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> if running with sufficient privileges; otherwise, <c>false</c>.</returns>
|
||||
bool HasRequiredPrivileges();
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the partition table information for a given disk identifier.
|
||||
/// </summary>
|
||||
/// <param name="diskIdentifier">The identifier of the disk.</param>
|
||||
/// <returns>The partition table geometry of the disk.</returns>
|
||||
PartitionTableGeometry GetPartitionTable(string diskIdentifier);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the partition information for a given disk identifier.
|
||||
/// </summary>
|
||||
/// <param name="diskIdentifier">The identifier of the disk.</param>
|
||||
/// <returns>An array of partition geometries for the disk.</returns>
|
||||
PartitionGeometry[] GetPartitions(string diskIdentifier);
|
||||
|
||||
/// <summary>
|
||||
/// Flushes the specified disk. Important prior to reading the disk raw.
|
||||
/// </summary>
|
||||
/// <param name="diskIdentifier">The identifier of the disk to flush.</param>
|
||||
void FlushDisk(string diskIdentifier);
|
||||
|
||||
/// <summary>
|
||||
/// Re-attaches the disk, optionally as read-only. This is useful for ensuring the disk is in a clean state after unmounting, and can also be used to force a read-only re-attachment for testing read-only scenarios.
|
||||
/// </summary>
|
||||
/// <param name="imagePath">The path to the disk image file.</param>
|
||||
/// <param name="diskIdentifier">The identifier of the disk to re-attach.</param>
|
||||
/// <param name="tableType">The type of partition table on the disk.</param>
|
||||
/// <param name="readOnly">Indicates whether the disk should be re-attached as read-only.</param>
|
||||
/// <returns>The identifier of the re-attached disk.</returns>
|
||||
string ReAttach(string imagePath, string diskIdentifier, PartitionTableType tableType, bool readOnly = false);
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
// 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.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using Duplicati.Proprietary.DiskImage;
|
||||
using NUnit.Framework;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Linux implementation of <see cref="IDiskImageHelper"/> using loop devices and standard Linux tools.
|
||||
/// </summary>
|
||||
internal class LinuxDiskImageHelper : IDiskImageHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks loop devices created by this helper for cleanup.
|
||||
/// </summary>
|
||||
private readonly List<string> _trackedLoopDevices = new();
|
||||
|
||||
/// <summary>
|
||||
/// Maps image paths to their associated loop devices.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, string> _imageToLoopDevice = new();
|
||||
|
||||
/// <summary>
|
||||
/// Tracks any mount directories that were automatically created for cleanup.
|
||||
/// </summary>
|
||||
private readonly List<string> _autoCreatedMntDirectories = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CreateDisk(string imagePath, long sizeB)
|
||||
{
|
||||
// Ensure the directory exists
|
||||
var directory = Path.GetDirectoryName(imagePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
// Delete existing image if it exists
|
||||
if (File.Exists(imagePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
CleanupDisk(imagePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Create a sparse disk image file using truncate
|
||||
RunProcess("truncate", $"-s {sizeB} \"{imagePath}\"");
|
||||
|
||||
// Attach it as a loop device
|
||||
var loopDevice = RunProcess("losetup", $"--find --show --partscan \"{imagePath}\"").Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(loopDevice) || !loopDevice.StartsWith("/dev/loop"))
|
||||
throw new InvalidOperationException($"Failed to attach loop device for {imagePath}");
|
||||
|
||||
// Track the loop device
|
||||
_trackedLoopDevices.Add(loopDevice);
|
||||
_imageToLoopDevice[imagePath] = loopDevice;
|
||||
|
||||
return loopDevice;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string[] InitializeDisk(string diskIdentifier, PartitionTableType tableType, (FileSystemType, long)[] partitions)
|
||||
{
|
||||
if (tableType == PartitionTableType.Unknown)
|
||||
return []; // No partition table, so nothing to initialize
|
||||
|
||||
// Create partition table using parted
|
||||
var label = tableType == PartitionTableType.GPT ? "gpt" : "msdos";
|
||||
RunProcess("parted", $"-s {diskIdentifier} mklabel {label}");
|
||||
|
||||
// Get disk size for calculating partition sizes
|
||||
var diskSize = GetDiskSize(diskIdentifier);
|
||||
var sectorSize = GetSectorSize(diskIdentifier);
|
||||
var mbrSize = 1 * sectorSize; // MBR requires 1 sector for the partition table
|
||||
var gptSize = mbrSize + 33 * sectorSize; // GPT requires 34 sectors reserved at the beginning (1 for protective MBR + 33 for GPT header and partition entries)
|
||||
long nextFreeSector = tableType == PartitionTableType.GPT ? gptSize : mbrSize;
|
||||
|
||||
// Create partitions
|
||||
for (int i = 0; i < partitions.Length; i++)
|
||||
{
|
||||
var (fsType, size) = partitions[i];
|
||||
var partitionNumber = i + 1;
|
||||
|
||||
// Calculate partition size
|
||||
long endOffset;
|
||||
if (size <= 0)
|
||||
{
|
||||
if (i != partitions.Length - 1)
|
||||
throw new InvalidOperationException("Only the last partition can be sized to fill the remaining space");
|
||||
endOffset = diskSize - sectorSize;
|
||||
}
|
||||
else
|
||||
endOffset = nextFreeSector + size - sectorSize;
|
||||
|
||||
// Check if we should clamp
|
||||
if (tableType == PartitionTableType.GPT && endOffset > diskSize - gptSize)
|
||||
if (size <= 0)
|
||||
endOffset = diskSize - gptSize;
|
||||
else
|
||||
throw new InvalidOperationException($"Partition {partitionNumber} exceeds available space. Max size for this partition is {diskSize - gptSize - nextFreeSector} bytes.");
|
||||
|
||||
// Create partition
|
||||
RunProcess("parted", $"-s {diskIdentifier} mkpart primary {nextFreeSector}B {endOffset}B");
|
||||
|
||||
// For MBR, set the boot flag on the first partition (some filesystems need this)
|
||||
if (tableType == PartitionTableType.MBR && i == 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
RunProcess("parted", $"-s {diskIdentifier} set {partitionNumber} boot on");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors setting boot flag
|
||||
}
|
||||
}
|
||||
|
||||
nextFreeSector = endOffset + sectorSize;
|
||||
}
|
||||
|
||||
// Re-read partition table
|
||||
RunProcess("partprobe", $"{diskIdentifier}");
|
||||
|
||||
// Small delay to allow kernel to create partition devices
|
||||
System.Threading.Thread.Sleep(200);
|
||||
|
||||
// Format each partition
|
||||
for (int i = 0; i < partitions.Length; i++)
|
||||
{
|
||||
var (fsType, _) = partitions[i];
|
||||
var partitionNumber = i + 1;
|
||||
var partitionDevice = GetPartitionDevice(diskIdentifier, partitionNumber);
|
||||
|
||||
// Wait for partition device to exist
|
||||
var retryCount = 0;
|
||||
while (!File.Exists(partitionDevice) && retryCount < 10)
|
||||
{
|
||||
System.Threading.Thread.Sleep(100);
|
||||
retryCount++;
|
||||
}
|
||||
|
||||
if (!File.Exists(partitionDevice))
|
||||
throw new InvalidOperationException($"Partition device {partitionDevice} did not appear after creation");
|
||||
|
||||
// Format the partition
|
||||
FormatPartition(partitionDevice, fsType);
|
||||
}
|
||||
|
||||
return partitions.Length == 0 ? [] : Mount(diskIdentifier, null, false, null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string[] Mount(string diskIdentifier, string? baseMountPath = null, bool readOnly = false, FileSystemType[]? fileSystemTypes = null)
|
||||
{
|
||||
var mountPoints = new List<string>();
|
||||
var partitions = GetPartitionNames(diskIdentifier);
|
||||
|
||||
if (partitions.Count == 0)
|
||||
throw new InvalidOperationException($"No partitions found for disk {diskIdentifier}");
|
||||
if (fileSystemTypes is not null && fileSystemTypes.Length != partitions.Count)
|
||||
throw new ArgumentException($"Length of fileSystemTypes array ({fileSystemTypes.Length}) must match the number of partitions ({partitions.Count})");
|
||||
|
||||
for (int i = 0; i < partitions.Count; i++)
|
||||
{
|
||||
var partitionDevice = partitions[i];
|
||||
|
||||
// Get filesystem type to handle special cases
|
||||
FileSystemType? fsType = null;
|
||||
if (fileSystemTypes is not null)
|
||||
fsType = fileSystemTypes[i];
|
||||
|
||||
// Create mount point
|
||||
string mountPoint;
|
||||
if (!string.IsNullOrEmpty(baseMountPath))
|
||||
{
|
||||
var partitionName = Path.GetFileName(partitionDevice);
|
||||
mountPoint = Path.Combine(baseMountPath, partitionName);
|
||||
}
|
||||
else
|
||||
{
|
||||
mountPoint = $"/mnt/duplicati_{Path.GetFileName(partitionDevice)}_{Guid.NewGuid():N}";
|
||||
_autoCreatedMntDirectories.Add(mountPoint);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(mountPoint);
|
||||
|
||||
// Mount the partition
|
||||
List<string> options = [];
|
||||
if (readOnly)
|
||||
options.Add("ro");
|
||||
if (fsType == FileSystemType.XFS)
|
||||
options.Add("nouuid"); // XFS does not allow mounting with duplicate UUIDs, so we ignore UUIDs for testing
|
||||
var optionsArg = options.Count > 0 ? $"-o {string.Join(",", options)}" : "";
|
||||
var mountArgs = $"{optionsArg} {partitionDevice} \"{mountPoint}\"".Trim();
|
||||
RunProcess("mount", mountArgs);
|
||||
|
||||
mountPoints.Add(mountPoint);
|
||||
}
|
||||
|
||||
return mountPoints.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Unmount(string diskIdentifier)
|
||||
{
|
||||
// Find all mounted partitions of this device
|
||||
var partitions = GetPartitionNames(diskIdentifier);
|
||||
|
||||
foreach (var partitionDevice in partitions)
|
||||
{
|
||||
// Check if mounted
|
||||
var mountInfo = GetMountPoint(partitionDevice);
|
||||
if (!string.IsNullOrEmpty(mountInfo))
|
||||
{
|
||||
RunProcess("umount", partitionDevice);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CleanupDisk(string imagePath, string? diskIdentifier = null)
|
||||
{
|
||||
// Determine the loop device if not provided
|
||||
if (string.IsNullOrEmpty(diskIdentifier))
|
||||
{
|
||||
if (_imageToLoopDevice.TryGetValue(imagePath, out var trackedDevice))
|
||||
{
|
||||
diskIdentifier = trackedDevice;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try to find the loop device using losetup -j
|
||||
try
|
||||
{
|
||||
var output = RunProcess("losetup", $"-j \"{imagePath}\"");
|
||||
if (!string.IsNullOrEmpty(output))
|
||||
{
|
||||
// Output format: /dev/loop0: [0005]:123456 (/path/to/image)
|
||||
var match = Regex.Match(output, @"^(\S+):");
|
||||
if (match.Success)
|
||||
diskIdentifier = match.Groups[1].Value;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unmount partitions
|
||||
if (!string.IsNullOrEmpty(diskIdentifier))
|
||||
{
|
||||
bool unmounted = false;
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
Unmount(diskIdentifier);
|
||||
unmounted = true;
|
||||
break;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Try to wait
|
||||
Thread.Sleep(1000);
|
||||
}
|
||||
}
|
||||
if (!unmounted)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to unmount partitions for {diskIdentifier} after multiple attempts.");
|
||||
}
|
||||
|
||||
// Detach loop device
|
||||
try
|
||||
{
|
||||
RunProcess("losetup", $"-d {diskIdentifier}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to detach loop device {diskIdentifier}: {ex.Message}");
|
||||
}
|
||||
|
||||
_trackedLoopDevices.Remove(diskIdentifier);
|
||||
}
|
||||
|
||||
// Delete the image file
|
||||
if (File.Exists(imagePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(imagePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to delete image file {imagePath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
_imageToLoopDevice.Remove(imagePath);
|
||||
|
||||
// Clean up any auto-created mount directories
|
||||
foreach (var mntDir in _autoCreatedMntDirectories)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(mntDir))
|
||||
Directory.Delete(mntDir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to delete mount directory {mntDir}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
_autoCreatedMntDirectories.Clear();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasRequiredPrivileges()
|
||||
{
|
||||
// Check if running as root (uid 0)
|
||||
try
|
||||
{
|
||||
var uid = RunProcess("id", "-u").Trim();
|
||||
return uid == "0";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PartitionTableGeometry GetPartitionTable(string diskIdentifier)
|
||||
{
|
||||
var deviceName = Path.GetFileName(diskIdentifier);
|
||||
var tableType = PartitionTableType.Unknown;
|
||||
|
||||
// Use blkid to get partition table type
|
||||
try
|
||||
{
|
||||
var output = RunProcess("blkid", $"-p -o value -s PTTYPE {diskIdentifier}").Trim();
|
||||
tableType = output.ToLower() switch
|
||||
{
|
||||
"gpt" => PartitionTableType.GPT,
|
||||
"dos" => PartitionTableType.MBR,
|
||||
"msdos" => PartitionTableType.MBR,
|
||||
_ => PartitionTableType.Unknown
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
var size = GetDiskSize(diskIdentifier);
|
||||
var sectorSize = GetSectorSize(diskIdentifier);
|
||||
|
||||
return new PartitionTableGeometry
|
||||
{
|
||||
Type = tableType,
|
||||
Size = size,
|
||||
SectorSize = sectorSize
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PartitionGeometry[] GetPartitions(string diskIdentifier)
|
||||
{
|
||||
var partitions = new List<PartitionGeometry>();
|
||||
var partitionNames = GetPartitionNames(diskIdentifier);
|
||||
|
||||
for (int i = 0; i < partitionNames.Count; i++)
|
||||
{
|
||||
var partitionDevice = partitionNames[i];
|
||||
var partitionNumber = i + 1;
|
||||
|
||||
try
|
||||
{
|
||||
// Get partition info using blockdev
|
||||
var start = 0L;
|
||||
var size = 0L;
|
||||
|
||||
try
|
||||
{
|
||||
var devName = Path.GetFileName(partitionDevice);
|
||||
var startFile = $"/sys/class/block/{devName}/start";
|
||||
if (File.Exists(startFile))
|
||||
{
|
||||
var startStr = File.ReadAllText(startFile).Trim();
|
||||
if (long.TryParse(startStr, out var startSector))
|
||||
{
|
||||
start = startSector * 512;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var startStr = RunProcess("blockdev", $"--getstart {partitionDevice}").Trim();
|
||||
if (long.TryParse(startStr, out var startSector))
|
||||
{
|
||||
// blockdev --getstart always returns the start sector in 512-byte units
|
||||
start = startSector * 512;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to get start for {partitionDevice}: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
size = GetDiskSize(partitionDevice);
|
||||
}
|
||||
catch { }
|
||||
|
||||
// Get filesystem type
|
||||
var fsType = FileSystemType.Unknown;
|
||||
try
|
||||
{
|
||||
var fsStr = RunProcess("blkid", $"-o value -s TYPE {partitionDevice}").Trim();
|
||||
fsType = ParseFilesystemType(fsStr);
|
||||
}
|
||||
catch { }
|
||||
|
||||
partitions.Add(new PartitionGeometry
|
||||
{
|
||||
Number = partitionNumber,
|
||||
Type = PartitionType.Unknown,
|
||||
StartOffset = start,
|
||||
Size = size,
|
||||
FilesystemType = fsType,
|
||||
TableType = PartitionTableType.Unknown
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to get info for partition {partitionDevice}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return partitions.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void FlushDisk(string diskIdentifier)
|
||||
{
|
||||
// Sync all filesystems
|
||||
RunProcess("sync", "");
|
||||
|
||||
// Flush buffers for the specific device
|
||||
try
|
||||
{
|
||||
RunProcess("blockdev", $"--flushbufs {diskIdentifier}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ReAttach(string imagePath, string diskIdentifier, PartitionTableType tableType, bool readOnly = false)
|
||||
{
|
||||
// Unmount and detach
|
||||
try
|
||||
{
|
||||
Unmount(diskIdentifier);
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
RunProcess("losetup", $"-d {diskIdentifier}");
|
||||
}
|
||||
catch { }
|
||||
|
||||
// Re-attach the loop device
|
||||
var readOnlyArg = readOnly ? "--read-only" : "";
|
||||
var newLoopDevice = RunProcess("losetup", $"--find --show --partscan {readOnlyArg} \"{imagePath}\"").Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(newLoopDevice) || !newLoopDevice.StartsWith("/dev/loop"))
|
||||
throw new InvalidOperationException($"Failed to re-attach loop device for {imagePath}");
|
||||
|
||||
// Update tracking
|
||||
_trackedLoopDevices.Remove(diskIdentifier);
|
||||
_trackedLoopDevices.Add(newLoopDevice);
|
||||
_imageToLoopDevice[imagePath] = newLoopDevice;
|
||||
|
||||
// Allow time for partition discovery
|
||||
System.Threading.Thread.Sleep(200);
|
||||
|
||||
return newLoopDevice;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
// Clean up any remaining tracked loop devices
|
||||
foreach (var loopDevice in _trackedLoopDevices.ToList())
|
||||
{
|
||||
try
|
||||
{
|
||||
// Find associated image
|
||||
var imagePath = _imageToLoopDevice.FirstOrDefault(x => x.Value == loopDevice).Key;
|
||||
if (!string.IsNullOrEmpty(imagePath))
|
||||
{
|
||||
CleanupDisk(imagePath, loopDevice);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Just try to detach the loop device
|
||||
RunProcess("losetup", $"-d {loopDevice}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to dispose loop device {loopDevice}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
_trackedLoopDevices.Clear();
|
||||
_imageToLoopDevice.Clear();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Runs a process and returns the output.
|
||||
/// </summary>
|
||||
private static string RunProcess(string fileName, string arguments)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
Arguments = arguments,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
process.Start();
|
||||
string output = process.StandardOutput.ReadToEnd();
|
||||
string error = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Process {fileName} exited with code {process.ExitCode}: {error}");
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the partition device path for a given disk and partition number.
|
||||
/// </summary>
|
||||
private static string GetPartitionDevice(string diskIdentifier, int partitionNumber)
|
||||
{
|
||||
// For loop devices, partitions are named /dev/loop0p1, /dev/loop0p2, etc.
|
||||
if (diskIdentifier.StartsWith("/dev/loop"))
|
||||
{
|
||||
return $"{diskIdentifier}p{partitionNumber}";
|
||||
}
|
||||
|
||||
// For NVMe devices, partitions are named /dev/nvme0n1p1, etc.
|
||||
if (diskIdentifier.Contains("nvme"))
|
||||
{
|
||||
return $"{diskIdentifier}p{partitionNumber}";
|
||||
}
|
||||
|
||||
// For standard SCSI/SATA devices, partitions are named /dev/sda1, /dev/sda2, etc.
|
||||
return $"{diskIdentifier}{partitionNumber}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of partition device paths for a disk.
|
||||
/// </summary>
|
||||
private static List<string> GetPartitionNames(string diskIdentifier)
|
||||
{
|
||||
var partitions = new List<string>();
|
||||
var deviceName = Path.GetFileName(diskIdentifier);
|
||||
|
||||
// Check for partition devices
|
||||
if (diskIdentifier.StartsWith("/dev/loop"))
|
||||
{
|
||||
// Loop device partitions: /dev/loop0p1, /dev/loop0p2, etc.
|
||||
for (int i = 1; i <= 16; i++)
|
||||
{
|
||||
var partitionDevice = $"{diskIdentifier}p{i}";
|
||||
if (File.Exists(partitionDevice))
|
||||
partitions.Add(partitionDevice);
|
||||
else if (i > 1)
|
||||
break; // Stop after first missing partition
|
||||
}
|
||||
}
|
||||
else if (diskIdentifier.Contains("nvme"))
|
||||
{
|
||||
// NVMe partitions: /dev/nvme0n1p1, etc.
|
||||
for (int i = 1; i <= 16; i++)
|
||||
{
|
||||
var partitionDevice = $"{diskIdentifier}p{i}";
|
||||
if (File.Exists(partitionDevice))
|
||||
partitions.Add(partitionDevice);
|
||||
else if (i > 1)
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standard partitions: /dev/sda1, /dev/sda2, etc.
|
||||
for (int i = 1; i <= 16; i++)
|
||||
{
|
||||
var partitionDevice = $"{diskIdentifier}{i}";
|
||||
if (File.Exists(partitionDevice))
|
||||
partitions.Add(partitionDevice);
|
||||
else if (i > 1)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return partitions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the mount point for a partition device.
|
||||
/// </summary>
|
||||
private static string? GetMountPoint(string partitionDevice)
|
||||
{
|
||||
try
|
||||
{
|
||||
var output = RunProcess("findmnt", $"--raw --noheadings -o TARGET {partitionDevice}").Trim();
|
||||
return string.IsNullOrEmpty(output) ? null : output;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the filesystem type of a partition.
|
||||
/// </summary>
|
||||
private static string GetFilesystemType(string partitionDevice)
|
||||
{
|
||||
try
|
||||
{
|
||||
return RunProcess("blkid", $"-o value -s TYPE {partitionDevice}").Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a partition with the specified filesystem type.
|
||||
/// </summary>
|
||||
private static void FormatPartition(string partitionDevice, FileSystemType fsType)
|
||||
{
|
||||
var (command, args) = fsType switch
|
||||
{
|
||||
FileSystemType.FAT12 => ("mkfs.vfat", $"-F 12 {partitionDevice}"),
|
||||
FileSystemType.FAT16 => ("mkfs.vfat", $"-F 16 {partitionDevice}"),
|
||||
FileSystemType.FAT32 => ("mkfs.vfat", $"-F 32 {partitionDevice}"),
|
||||
FileSystemType.ExFAT => ("mkfs.exfat", $"{partitionDevice}"),
|
||||
FileSystemType.Ext2 => ("mkfs.ext2", $"-F {partitionDevice}"),
|
||||
FileSystemType.Ext3 => ("mkfs.ext3", $"-F {partitionDevice}"),
|
||||
FileSystemType.Ext4 => ("mkfs.ext4", $"-F {partitionDevice}"),
|
||||
FileSystemType.XFS => ("mkfs.xfs", $"-f {partitionDevice}"),
|
||||
FileSystemType.Btrfs => ("mkfs.btrfs", $"-f {partitionDevice}"),
|
||||
FileSystemType.NTFS => ("mkfs.ntfs", $"-F {partitionDevice}"),
|
||||
FileSystemType.ZFS => throw new NotSupportedException("ZFS is not supported for testing. ZFS requires pool creation which is incompatible with standard partition-based testing."),
|
||||
_ => throw new NotSupportedException($"Unsupported filesystem type on Linux: {fsType}")
|
||||
};
|
||||
|
||||
RunProcess(command, args);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the size of a disk or partition in bytes.
|
||||
/// </summary>
|
||||
private static long GetDiskSize(string devicePath)
|
||||
{
|
||||
var output = RunProcess("blockdev", $"--getsize64 {devicePath}").Trim();
|
||||
return long.TryParse(output, out var size) ? size : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the sector size of a disk.
|
||||
/// </summary>
|
||||
private static int GetSectorSize(string devicePath)
|
||||
{
|
||||
var output = RunProcess("blockdev", $"--getss {devicePath}").Trim();
|
||||
return int.TryParse(output, out var size) ? size : 512;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a filesystem type string to the FileSystemType enum.
|
||||
/// </summary>
|
||||
private static FileSystemType ParseFilesystemType(string fsType)
|
||||
{
|
||||
return fsType.ToLower() switch
|
||||
{
|
||||
"ntfs" => FileSystemType.NTFS,
|
||||
"vfat" or "fat" => FileSystemType.FAT32, // Could be FAT12/16/32
|
||||
"exfat" => FileSystemType.ExFAT,
|
||||
"hfsplus" or "hfs+" => FileSystemType.HFSPlus,
|
||||
"apfs" => FileSystemType.APFS,
|
||||
"ext2" => FileSystemType.Ext2,
|
||||
"ext3" => FileSystemType.Ext3,
|
||||
"ext4" => FileSystemType.Ext4,
|
||||
"xfs" => FileSystemType.XFS,
|
||||
"btrfs" => FileSystemType.Btrfs,
|
||||
"zfs" => FileSystemType.ZFS,
|
||||
"refs" => FileSystemType.ReFS,
|
||||
_ => FileSystemType.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
// 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.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Xml.Linq;
|
||||
using Duplicati.Library.Utility;
|
||||
using Duplicati.Proprietary.DiskImage;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
using NUnit.Framework;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// macOS implementation of <see cref="IDiskImageHelper"/> using hdiutil and standard macOS tools.
|
||||
/// </summary>
|
||||
internal class MacOSDiskImageHelper : IDiskImageHelper
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CreateDisk(string imagePath, long sizeB)
|
||||
{
|
||||
// Ensure the directory exists
|
||||
var directory = Path.GetDirectoryName(imagePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
// Delete existing image if it exists
|
||||
if (File.Exists(imagePath))
|
||||
try
|
||||
{
|
||||
CleanupDisk(imagePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
|
||||
// Create a raw disk image using hdiutil
|
||||
// Use UDIF format with "Free Space" filesystem (unformatted)
|
||||
RunProcess("hdiutil", $"create -size {sizeB} -type UDIF -layout NONE -o \"{imagePath}\"");
|
||||
|
||||
var output = RunProcess("hdiutil", $"attach -nomount -noautofsck \"{imagePath}\"");
|
||||
|
||||
// Parse the disk device from output (e.g., "/dev/disk4")
|
||||
var diskDevice = output.Trim().Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim()
|
||||
?? throw new InvalidOperationException("Failed to get disk device from hdiutil attach output");
|
||||
|
||||
// Return the raw device path for direct I/O
|
||||
return diskDevice.Replace("/dev/disk", "/dev/rdisk");
|
||||
}
|
||||
|
||||
private string[] GetPartitionNames(string diskIdentifier)
|
||||
{
|
||||
var output = RunProcess("diskutil", $"list -plist {diskIdentifier}");
|
||||
|
||||
var plist = XDocument.Parse(output);
|
||||
var rootDict = plist.Element("plist")?.Element("dict");
|
||||
if (rootDict == null)
|
||||
return [];
|
||||
|
||||
// Find the disk in AllDisksAndPartitions
|
||||
var allDisksAndPartitions = PlistHelper.GetArrayElement(rootDict, "AllDisksAndPartitions");
|
||||
if (allDisksAndPartitions == null)
|
||||
return [];
|
||||
|
||||
// Normalize disk identifier - plist stores just "disk4" but we might have "/dev/disk4" or "/dev/rdisk4"
|
||||
var normalizedId = diskIdentifier.Replace("/dev/rdisk", "disk").Replace("/dev/disk", "disk");
|
||||
|
||||
var diskElement = allDisksAndPartitions.Elements("dict")
|
||||
.FirstOrDefault(d => PlistHelper.GetStringValue(d, "DeviceIdentifier") == normalizedId);
|
||||
|
||||
if (diskElement == null)
|
||||
return [];
|
||||
|
||||
var partitions = new List<string>();
|
||||
|
||||
// Check for regular partitions
|
||||
var partitionsArray = PlistHelper.GetArrayElement(diskElement, "Partitions");
|
||||
if (partitionsArray != null)
|
||||
{
|
||||
foreach (var partition in partitionsArray.Elements("dict"))
|
||||
{
|
||||
var deviceIdentifier = PlistHelper.GetStringValue(partition, "DeviceIdentifier");
|
||||
if (!string.IsNullOrWhiteSpace(deviceIdentifier))
|
||||
partitions.Add(deviceIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for APFS volumes
|
||||
var apfsVolumes = PlistHelper.GetArrayElement(diskElement, "APFSVolumes");
|
||||
if (apfsVolumes != null)
|
||||
{
|
||||
foreach (var volume in apfsVolumes.Elements("dict"))
|
||||
{
|
||||
var deviceIdentifier = PlistHelper.GetStringValue(volume, "DeviceIdentifier");
|
||||
if (!string.IsNullOrWhiteSpace(deviceIdentifier))
|
||||
partitions.Add(deviceIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
return [.. partitions];
|
||||
}
|
||||
|
||||
private string[] GetMountPoints(string diskIdentifier)
|
||||
{
|
||||
var partitions = GetPartitionNames(diskIdentifier);
|
||||
var mountPoints = new System.Collections.Generic.List<string>();
|
||||
|
||||
foreach (var partition in partitions)
|
||||
{
|
||||
var output = RunProcess("diskutil", $"info -plist /dev/{partition}");
|
||||
var plist = XDocument.Parse(output);
|
||||
var dict = plist.Element("plist")?.Element("dict");
|
||||
if (dict == null)
|
||||
continue;
|
||||
|
||||
// Check if this is an APFS Physical Store
|
||||
var contentType = PlistHelper.GetStringValue(dict, "Content");
|
||||
var isApfsPhysicalStore = contentType == "Apple_APFS";
|
||||
|
||||
if (isApfsPhysicalStore)
|
||||
{
|
||||
// For APFS, get the container and then get volumes from it
|
||||
var containerId = PlistHelper.GetStringValue(dict, "APFSContainerReference");
|
||||
if (!string.IsNullOrEmpty(containerId))
|
||||
{
|
||||
// Get volumes from the container (they are like disk5s1, disk5s2, etc.)
|
||||
var containerVolumes = GetApfsContainerVolumes(containerId);
|
||||
foreach (var volume in containerVolumes)
|
||||
{
|
||||
var volumeOutput = RunProcess("diskutil", $"info -plist {volume}");
|
||||
var volumePlist = XDocument.Parse(volumeOutput);
|
||||
var volumeDict = volumePlist.Element("plist")?.Element("dict");
|
||||
if (volumeDict != null)
|
||||
{
|
||||
var mountPoint = PlistHelper.GetStringValue(volumeDict, "MountPoint");
|
||||
if (!string.IsNullOrEmpty(mountPoint))
|
||||
mountPoints.Add(mountPoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Regular partition - extract mount point directly
|
||||
var mountPoint = PlistHelper.GetStringValue(dict, "MountPoint");
|
||||
if (!string.IsNullOrEmpty(mountPoint))
|
||||
mountPoints.Add(mountPoint);
|
||||
}
|
||||
}
|
||||
|
||||
return [.. mountPoints];
|
||||
}
|
||||
|
||||
private string[] GetApfsContainerVolumes(string containerId)
|
||||
{
|
||||
var output = RunProcess("diskutil", $"list -plist {containerId}");
|
||||
|
||||
var plist = XDocument.Parse(output);
|
||||
var rootDict = plist.Element("plist")?.Element("dict");
|
||||
if (rootDict == null)
|
||||
return [];
|
||||
|
||||
var allDisksAndPartitions = PlistHelper.GetArrayElement(rootDict, "AllDisksAndPartitions");
|
||||
if (allDisksAndPartitions == null)
|
||||
return [];
|
||||
|
||||
// Normalize container id - plist stores just "disk4" but we might have "/dev/disk4" or "/dev/rdisk4"
|
||||
var normalizedId = containerId.Replace("/dev/rdisk", "disk").Replace("/dev/disk", "disk");
|
||||
|
||||
var container = allDisksAndPartitions.Elements("dict")
|
||||
.FirstOrDefault(d => PlistHelper.GetStringValue(d, "DeviceIdentifier") == normalizedId);
|
||||
|
||||
if (container == null)
|
||||
return [];
|
||||
|
||||
var volumes = new List<string>();
|
||||
var apfsVolumes = PlistHelper.GetArrayElement(container, "APFSVolumes");
|
||||
if (apfsVolumes != null)
|
||||
{
|
||||
foreach (var volume in apfsVolumes.Elements("dict"))
|
||||
{
|
||||
var deviceIdentifier = PlistHelper.GetStringValue(volume, "DeviceIdentifier");
|
||||
if (!string.IsNullOrWhiteSpace(deviceIdentifier))
|
||||
volumes.Add(deviceIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
return [.. volumes];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string[] InitializeDisk(string diskIdentifier, PartitionTableType tableType, (FileSystemType, long)[] partitions)
|
||||
{
|
||||
if (tableType == PartitionTableType.Unknown)
|
||||
return []; // No partition table, so nothing to initialize
|
||||
|
||||
// Use diskutil to partition the disk with the specified scheme
|
||||
// "Free Space" means no filesystem, just the partition table
|
||||
var partitionStrings = partitions.Length > 0 ? partitions.Select((p, i) =>
|
||||
{
|
||||
var fsType = p.Item1 switch
|
||||
{
|
||||
FileSystemType.NTFS => throw new NotSupportedException("NTFS is not natively supported on macOS for creating partitions. Use ExFAT or FAT32 instead."),
|
||||
FileSystemType.FAT32 => "\"MS-DOS FAT32\"",
|
||||
FileSystemType.ExFAT => "ExFAT",
|
||||
FileSystemType.HFSPlus => "HFS+",
|
||||
FileSystemType.APFS => "APFS",
|
||||
_ => throw new ArgumentException($"Unsupported filesystem type on macOS: {p.Item1}", nameof(partitions))
|
||||
};
|
||||
|
||||
var sizeArg = p.Item2 > 0 ? $"{p.Item2}" : "0";
|
||||
return $"{fsType} \"Partition {i + 1}\" {sizeArg}";
|
||||
})
|
||||
: ["\"Free Space\" \"\" 0"];
|
||||
var partitionArgs = string.Join(" ", partitionStrings);
|
||||
|
||||
// Normalize disk identifier for diskutil command (it expects "disk4", not "/dev/rdisk4")
|
||||
var normalizedDiskId = diskIdentifier.Replace("/dev/rdisk", "disk").Replace("/dev/disk", "disk");
|
||||
RunProcess("diskutil", $"partitionDisk {normalizedDiskId} {tableType.ToString().ToUpperInvariant()} {partitionArgs}");
|
||||
|
||||
if (partitions.Length == 0)
|
||||
return [];
|
||||
|
||||
return GetMountPoints(diskIdentifier);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string[] Mount(string diskIdentifier, string? baseMountPath = null, bool readOnly = false, FileSystemType[]? fileSystemTypes = null)
|
||||
{
|
||||
// Check if already mounted
|
||||
try
|
||||
{
|
||||
var existingMountPoints = GetMountPoints(diskIdentifier);
|
||||
if (existingMountPoints.Length > 0 && existingMountPoints.All(mp => mp.StartsWith(baseMountPath ?? "/Volumes/")))
|
||||
{
|
||||
return existingMountPoints;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors and try mounting
|
||||
}
|
||||
|
||||
// Get the list of partitions first to ensure we target the correct disk
|
||||
var partitions = GetPartitionNames(diskIdentifier);
|
||||
|
||||
List<string> mountPoints = [];
|
||||
foreach (var partition in partitions)
|
||||
{
|
||||
var output = RunProcess("diskutil", $"info -plist /dev/{partition}");
|
||||
var plist = XDocument.Parse(output);
|
||||
var dict = plist.Element("plist")?.Element("dict");
|
||||
if (dict == null)
|
||||
continue;
|
||||
|
||||
// Check if this is an APFS Physical Store
|
||||
var contentType = PlistHelper.GetStringValue(dict, "Content");
|
||||
var isApfsPhysicalStore = contentType == "Apple_APFS";
|
||||
|
||||
if (isApfsPhysicalStore)
|
||||
{
|
||||
// For APFS, get the container and mount volumes from it
|
||||
var containerId = PlistHelper.GetStringValue(dict, "APFSContainerReference");
|
||||
if (!string.IsNullOrEmpty(containerId))
|
||||
{
|
||||
// Get volumes from the container (they are like disk5s1, disk5s2, etc.)
|
||||
var containerVolumes = GetApfsContainerVolumes(containerId);
|
||||
foreach (var volume in containerVolumes)
|
||||
{
|
||||
MountVolume(volume, baseMountPath, mountPoints, readOnly);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Regular partition - mount directly
|
||||
MountVolume(partition, baseMountPath, mountPoints, readOnly);
|
||||
}
|
||||
}
|
||||
|
||||
return mountPoints.Count == 0 ? WaitForMountPoints(diskIdentifier) : [.. mountPoints];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mounts a single volume/partition with optional custom mount point.
|
||||
/// </summary>
|
||||
/// <param name="volume">The volume identifier (e.g., "disk4s1").</param>
|
||||
/// <param name="baseMountPath">Optional base path for mounting.</param>
|
||||
/// <param name="mountPoints">List to collect mount points.</param>
|
||||
/// <param name="readOnly">Indicates whether the volume should be mounted as read-only.</param>
|
||||
private void MountVolume(string volume, string? baseMountPath, List<string> mountPoints, bool readOnly)
|
||||
{
|
||||
var dir = "";
|
||||
var mountArgs = readOnly ? "-readOnly" : "";
|
||||
if (baseMountPath is not null)
|
||||
{
|
||||
dir = Path.Combine(baseMountPath, volume);
|
||||
Directory.CreateDirectory(dir); // diskutil will mount to this directory instead of /Volumes/ if it exists, so create it ahead of time
|
||||
var readOnlyArg = readOnly ? " -readOnly" : "";
|
||||
mountArgs = $"{readOnlyArg} -mountPoint \"{dir}\"";
|
||||
}
|
||||
|
||||
// Unmount first in case it's already mounted somewhere else, to ensure it gets mounted to the correct location
|
||||
var oldMp = GetMountPointFromPlist(RunProcess("diskutil", $"info -plist /dev/{volume}"));
|
||||
if (oldMp is not null && !oldMp.StartsWith(baseMountPath ?? "/Volumes/"))
|
||||
RunProcess("diskutil", $"unmount force /dev/{volume}");
|
||||
|
||||
RunProcess("diskutil", $"mount -nobrowse {mountArgs} /dev/{volume}");
|
||||
|
||||
// Verify the mount
|
||||
var mp = RunProcess("diskutil", $"info -plist /dev/{volume}");
|
||||
var mountPoint = GetMountPointFromPlist(mp);
|
||||
if (!string.IsNullOrEmpty(mountPoint))
|
||||
{
|
||||
mountPoints.Add(mountPoint);
|
||||
|
||||
if (!mountPoint.StartsWith(baseMountPath ?? "/Volumes/"))
|
||||
{
|
||||
Console.WriteLine($"Warning: Volume /dev/{volume} did not mount to expected location. Output: {mp}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Warning: Volume /dev/{volume} did not report a mount point after mounting. Output: {mp}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the mount point from diskutil info -plist output.
|
||||
/// </summary>
|
||||
private string? GetMountPointFromPlist(string plistOutput)
|
||||
{
|
||||
try
|
||||
{
|
||||
var plist = XDocument.Parse(plistOutput);
|
||||
var dict = plist.Element("plist")?.Element("dict");
|
||||
if (dict == null)
|
||||
return null;
|
||||
|
||||
var mountPoint = PlistHelper.GetStringValue(dict, "MountPoint");
|
||||
// Return null if not mounted (empty string indicates not mounted)
|
||||
return string.IsNullOrWhiteSpace(mountPoint) ? null : mountPoint;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for mount points to be fully established after a mount operation.
|
||||
/// Uses exponential backoff starting from a short delay to minimize wait time
|
||||
/// in the common case while still handling slow mounts.
|
||||
///
|
||||
/// After mounting (especially after raw partition table writes during restore),
|
||||
/// macOS may need time to fully recognize filesystems and establish mount points
|
||||
/// in /Volumes/. Without this retry loop, mount points may be:
|
||||
/// - Not yet available (empty array)
|
||||
/// - In a transient location like /private/var/ instead of /Volumes/
|
||||
/// - Mounted but with empty directories (filesystem not yet recognized)
|
||||
/// </summary>
|
||||
/// <param name="diskIdentifier">The disk identifier to check mount points for.</param>
|
||||
/// <returns>An array of mount point paths.</returns>
|
||||
private string[] WaitForMountPoints(string diskIdentifier)
|
||||
{
|
||||
const int maxRetries = 10;
|
||||
const int initialDelayMs = 100;
|
||||
const int maxDelayMs = 1000;
|
||||
|
||||
var delayMs = initialDelayMs;
|
||||
for (int i = 0; i < maxRetries; i++)
|
||||
{
|
||||
var mountPoints = GetMountPoints(diskIdentifier);
|
||||
|
||||
if (mountPoints.Length > 0)
|
||||
{
|
||||
// Check if any mount points are in a transient location (not under /Volumes/)
|
||||
if (mountPoints.Any(mp => !mp.StartsWith("/Volumes/")))
|
||||
{
|
||||
Thread.Sleep(delayMs);
|
||||
delayMs = Math.Min(delayMs * 2, maxDelayMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if all mount points have filesystem entries (not empty)
|
||||
var allNonEmpty = mountPoints.All(mp =>
|
||||
{
|
||||
try { return Directory.GetFileSystemEntries(mp).Length > 0; }
|
||||
catch { return false; }
|
||||
});
|
||||
|
||||
if (allNonEmpty || i == maxRetries - 1)
|
||||
return mountPoints;
|
||||
}
|
||||
|
||||
Thread.Sleep(delayMs);
|
||||
delayMs = Math.Min(delayMs * 2, maxDelayMs);
|
||||
}
|
||||
|
||||
// Final attempt - return whatever we have
|
||||
return GetMountPoints(diskIdentifier);
|
||||
}
|
||||
|
||||
public void Unmount(string diskIdentifier)
|
||||
{
|
||||
int retryCount = 3;
|
||||
for (int i = 0; i < retryCount; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Unmount all volumes on the disk
|
||||
RunProcess("diskutil", $"unmountDisk {diskIdentifier}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Sometimes an unmount is issued too fast after writing data, which makes diskutil throw an errory.
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void FlushDisk(string diskIdentifier)
|
||||
{
|
||||
// On macOS, use sync to flush all filesystem buffers
|
||||
RunProcess("sync", "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CleanupDisk(string imagePath, string? diskIdentifier = null)
|
||||
{
|
||||
if (diskIdentifier is null)
|
||||
{
|
||||
var output = RunProcess("hdiutil", $"info");
|
||||
|
||||
diskIdentifier = output
|
||||
.Split("================================================")
|
||||
.Skip(1) // Skip the header
|
||||
.Select(d =>
|
||||
{
|
||||
var lines = d
|
||||
.Split('\n')
|
||||
.Select(l => l.Trim())
|
||||
.Where(l => l.Length > 0)
|
||||
.ToList();
|
||||
var imagePathLine = lines.FirstOrDefault(l => l.StartsWith("image-path"));
|
||||
if (imagePathLine == null)
|
||||
return null;
|
||||
|
||||
var path = imagePathLine.Split([':'], 2)[1].Trim();
|
||||
if (!string.Equals(path, imagePath, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
var deviceLine = lines.FirstOrDefault(l => l.StartsWith("/dev/"));
|
||||
if (deviceLine == null)
|
||||
return null;
|
||||
|
||||
var device = deviceLine
|
||||
.Split(['\t'], 2)[0]
|
||||
.Trim();
|
||||
|
||||
return device;
|
||||
})
|
||||
.FirstOrDefault(d => d != null);
|
||||
}
|
||||
|
||||
if (diskIdentifier is null)
|
||||
throw new InvalidOperationException($"Failed to determine disk identifier for {imagePath} during cleanup");
|
||||
|
||||
try
|
||||
{
|
||||
RunProcess("diskutil", $"eject {diskIdentifier}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: Failed to detach disk image: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(imagePath))
|
||||
File.Delete(imagePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: Failed to delete disk image file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasRequiredPrivileges()
|
||||
{
|
||||
// Root is not required for virtual disks.
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PartitionTableGeometry GetPartitionTable(string diskIdentifier)
|
||||
{
|
||||
var output = RunProcess("diskutil", $"info -plist {diskIdentifier}");
|
||||
|
||||
var plist = XDocument.Parse(output);
|
||||
var dict = plist.Element("plist")?.Element("dict");
|
||||
if (dict == null)
|
||||
throw new InvalidOperationException($"Failed to parse diskutil info plist for {diskIdentifier}");
|
||||
|
||||
// Verify disk identifier
|
||||
var deviceIdentifier = PlistHelper.GetStringValue(dict, "DeviceIdentifier");
|
||||
if (!diskIdentifier.EndsWith(deviceIdentifier ?? "", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"Disk identifier mismatch in diskutil info output: {diskIdentifier} doesn't end with {deviceIdentifier}");
|
||||
|
||||
// Determine partition table type
|
||||
var contentType = PlistHelper.GetStringValue(dict, "Content");
|
||||
var tableType = contentType switch
|
||||
{
|
||||
"GUID_partition_scheme" => PartitionTableType.GPT,
|
||||
"FDisk_partition_scheme" => PartitionTableType.MBR,
|
||||
_ => PartitionTableType.Unknown
|
||||
};
|
||||
|
||||
// Get size and sector size
|
||||
var size = PlistHelper.GetLongValue(dict, "TotalSize");
|
||||
var sectorSize = (int)PlistHelper.GetLongValue(dict, "DeviceBlockSize");
|
||||
|
||||
if (size == 0 || sectorSize == 0)
|
||||
throw new InvalidOperationException($"Failed to retrieve partition table information for disk {diskIdentifier}");
|
||||
|
||||
return new PartitionTableGeometry
|
||||
{
|
||||
Type = tableType,
|
||||
Size = size,
|
||||
SectorSize = sectorSize
|
||||
// Ignore the rest for now.
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PartitionGeometry[] GetPartitions(string diskIdentifier)
|
||||
{
|
||||
var names = GetPartitionNames(diskIdentifier);
|
||||
|
||||
var partitions = new List<PartitionGeometry>();
|
||||
foreach (var name in names)
|
||||
{
|
||||
var output = RunProcess("diskutil", $"info -plist /dev/{name}");
|
||||
|
||||
var plist = XDocument.Parse(output);
|
||||
var dict = plist.Element("plist")?.Element("dict");
|
||||
if (dict == null)
|
||||
continue;
|
||||
|
||||
// Verify device node
|
||||
var deviceNode = PlistHelper.GetStringValue(dict, "DeviceNode");
|
||||
if (!string.Equals(deviceNode, $"/dev/{name}", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"Partition identifier mismatch in diskutil info output: expected /dev/{name}, got {deviceNode}");
|
||||
|
||||
// Extract partition number from name (e.g., "disk4s1" -> 1)
|
||||
var partitionNumber = int.TryParse(
|
||||
name.LastIndexOf('s') >= 0 ? name[(name.LastIndexOf('s') + 1)..] : "",
|
||||
out int num) ? num : -1;
|
||||
|
||||
// Parse filesystem type
|
||||
var fsPersonality = PlistHelper.GetStringValue(dict, "FilesystemPersonality");
|
||||
var filesystemType = fsPersonality switch
|
||||
{
|
||||
"MS-DOS FAT32" => FileSystemType.FAT32,
|
||||
"ExFAT" => FileSystemType.ExFAT,
|
||||
"HFS+" => FileSystemType.HFSPlus,
|
||||
"APFS" => FileSystemType.APFS,
|
||||
_ => FileSystemType.Unknown
|
||||
};
|
||||
|
||||
// Parse volume UUID
|
||||
Guid? volumeGuid = null;
|
||||
var volumeUuid = PlistHelper.GetStringValue(dict, "VolumeUUID");
|
||||
if (Guid.TryParse(volumeUuid, out Guid parsedGuid))
|
||||
volumeGuid = parsedGuid;
|
||||
|
||||
PartitionGeometry partition = new()
|
||||
{
|
||||
Number = partitionNumber,
|
||||
Type = PartitionType.Unknown,
|
||||
StartOffset = PlistHelper.GetLongValue(dict, "PartitionMapPartitionOffset"),
|
||||
Size = PlistHelper.GetLongValue(dict, "TotalSize"),
|
||||
Name = PlistHelper.GetStringValue(dict, "VolumeName"),
|
||||
FilesystemType = filesystemType,
|
||||
VolumeGuid = volumeGuid,
|
||||
TableType = PartitionTableType.Unknown
|
||||
};
|
||||
|
||||
partitions.Add(partition);
|
||||
}
|
||||
|
||||
return partitions.ToArray();
|
||||
}
|
||||
|
||||
public string ReAttach(string imagePath, string diskIdentifier, PartitionTableType tableType, bool readOnly = false)
|
||||
{
|
||||
// Eject can fail if issued too fast after writing data, which makes diskutil throw an error. Retry a few times with a delay to mitigate this.
|
||||
RunProcess("diskutil", $"eject {diskIdentifier}", retryCount: 3);
|
||||
var readOnlyArg = readOnly ? " -readonly" : "";
|
||||
var reattachoutput = RunProcess("hdiutil", $"attach -nomount -noautofsck{readOnlyArg} \"{imagePath}\"");
|
||||
if (tableType == PartitionTableType.Unknown)
|
||||
return reattachoutput.Split('\n').First().Trim();
|
||||
else
|
||||
return reattachoutput
|
||||
.Split('\n')
|
||||
.Select(x => x.Trim()
|
||||
.Split(' ')
|
||||
.Where(y => !string.IsNullOrEmpty(y))
|
||||
.Select(y => y.Trim())
|
||||
.ToArray()
|
||||
)
|
||||
.Where(x => x.Length > 0 && x[^1].EndsWith("_partition_scheme"))
|
||||
.First().First();
|
||||
}
|
||||
|
||||
public static string RunProcess(string fileName, string arguments, int retryCount = 0)
|
||||
{
|
||||
for (int i = 0; i <= retryCount; i++)
|
||||
{
|
||||
var result = ProcessRunner.RunProcessAsync(fileName, arguments, 30_000).Await();
|
||||
|
||||
|
||||
if (result.ExitCode != 0)
|
||||
if (i < retryCount)
|
||||
{
|
||||
Console.WriteLine($"Warning: Process {fileName} with arguments {arguments} exited with code {result.ExitCode}. Retrying... Attempt {i + 1} of {retryCount}");
|
||||
Thread.Sleep(500);
|
||||
continue;
|
||||
}
|
||||
else
|
||||
throw new InvalidOperationException($"Process {fileName} with arguments {arguments} exited with code {result.ExitCode}: {result.Error}. Output: {result.Output}");
|
||||
|
||||
return result.Output;
|
||||
}
|
||||
|
||||
// This should never be hit due to the retry loop logic, but is required to satisfy the compiler.
|
||||
throw new InvalidOperationException($"Failed to run process {fileName} with arguments {arguments} after retries. This should not happen.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
// Nothing to dispose
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,858 @@
|
||||
// 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.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using Duplicati.Proprietary.DiskImage;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
using NUnit.Framework;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages a persistent PowerShell session for executing commands.
|
||||
/// This avoids the overhead of starting a new PowerShell process for each command.
|
||||
/// </summary>
|
||||
internal sealed class PowerShellSession : IDisposable
|
||||
{
|
||||
private Process? _process;
|
||||
private readonly object _lock = new();
|
||||
private bool _disposed;
|
||||
private readonly StringBuilder _outputBuffer = new();
|
||||
private readonly StringBuilder _errorBuffer = new();
|
||||
private readonly ManualResetEventSlim _outputAvailable = new(false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the PowerShell session is running.
|
||||
/// </summary>
|
||||
/// <value><c>true</c> if the session is running; otherwise, <c>false</c>.</value>
|
||||
public bool IsRunning => _process != null && !_process.HasExited;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the PowerShell session is started.
|
||||
/// </summary>
|
||||
/// <exception cref="ObjectDisposedException">Thrown if the session has been disposed.</exception>
|
||||
public void EnsureStarted()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(PowerShellSession));
|
||||
}
|
||||
|
||||
if (_process != null && !_process.HasExited)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StartProcess();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the PowerShell process with elevated privileges.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the PowerShell process cannot be started.</exception>
|
||||
private void StartProcess()
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
Arguments = "-NoProfile -ExecutionPolicy Bypass -Command \"& { while ($true) { $cmd = Read-Host; if ($cmd -eq 'EXIT_SESSION') { break }; try { $output = Invoke-Expression $cmd 2>&1; $output | Out-String -Stream; Write-Output \"___ENDOFCOMMAND___\" } catch { $_.Exception.Message; Write-Output \"___ENDOFCOMMAND___\" } } }\"",
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
Verb = "runas" // Request elevation
|
||||
};
|
||||
|
||||
_process = Process.Start(startInfo);
|
||||
if (_process == null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to start PowerShell process");
|
||||
}
|
||||
|
||||
// Clear any startup output
|
||||
_outputBuffer.Clear();
|
||||
_errorBuffer.Clear();
|
||||
_outputAvailable.Reset();
|
||||
|
||||
// Attach event handlers for async reading
|
||||
_process.OutputDataReceived += OnOutputDataReceived;
|
||||
_process.ErrorDataReceived += OnErrorDataReceived;
|
||||
|
||||
_process.BeginOutputReadLine();
|
||||
_process.BeginErrorReadLine();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the output data received from the PowerShell process.
|
||||
/// </summary>
|
||||
/// <param name="sender">The source of the event.</param>
|
||||
/// <param name="e">The event data.</param>
|
||||
private void OnOutputDataReceived(object sender, DataReceivedEventArgs e)
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
lock (_outputBuffer)
|
||||
{
|
||||
_outputBuffer.AppendLine(e.Data);
|
||||
if (e.Data.Contains("___ENDOFCOMMAND___"))
|
||||
{
|
||||
_outputAvailable.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the error data received from the PowerShell process.
|
||||
/// </summary>
|
||||
/// <param name="sender">The source of the event.</param>
|
||||
/// <param name="e">The event data.</param>
|
||||
private void OnErrorDataReceived(object sender, DataReceivedEventArgs e)
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
lock (_errorBuffer)
|
||||
{
|
||||
_errorBuffer.AppendLine(e.Data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a PowerShell script and returns the output.
|
||||
/// </summary>
|
||||
/// <param name="script">The PowerShell script to execute.</param>
|
||||
/// <param name="timeoutSeconds">The timeout in seconds for the script execution.</param>
|
||||
/// <returns>The output from PowerShell.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the PowerShell process is not running.</exception>
|
||||
/// <exception cref="TimeoutException">Thrown if the command times out.</exception>
|
||||
public string ExecuteScript(string script, int timeoutSeconds)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureStarted();
|
||||
|
||||
if (_process == null || _process.HasExited)
|
||||
{
|
||||
throw new InvalidOperationException("PowerShell process is not running");
|
||||
}
|
||||
|
||||
// Clear previous output and reset the event
|
||||
lock (_outputBuffer)
|
||||
{
|
||||
_outputBuffer.Clear();
|
||||
}
|
||||
lock (_errorBuffer)
|
||||
{
|
||||
_errorBuffer.Clear();
|
||||
}
|
||||
_outputAvailable.Reset();
|
||||
|
||||
// Write the script to stdin using base64 encoding to handle special characters
|
||||
var encodedScript = Convert.ToBase64String(Encoding.UTF8.GetBytes(script));
|
||||
var command = $"[System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encodedScript}')) | Invoke-Expression";
|
||||
|
||||
_process.StandardInput.WriteLine(command);
|
||||
|
||||
// Wait for output with a timeout
|
||||
var timeout = TimeSpan.FromSeconds(timeoutSeconds);
|
||||
if (!_outputAvailable.Wait(timeout))
|
||||
{
|
||||
throw new TimeoutException($"PowerShell command timed out after {timeout.TotalSeconds} seconds.\nScript:\n{script}");
|
||||
}
|
||||
|
||||
// Give a bit more time for all output to arrive
|
||||
Thread.Sleep(50);
|
||||
|
||||
lock (_outputBuffer)
|
||||
{
|
||||
var rawOutput = _outputBuffer.ToString();
|
||||
_outputBuffer.Clear();
|
||||
|
||||
// Remove the end marker and any lines that contain our base64 command
|
||||
var lines = rawOutput.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var filteredLines = lines
|
||||
.Where(line => !line.Contains("___ENDOFCOMMAND___"))
|
||||
.Where(line => !line.Contains("FromBase64String"))
|
||||
.Where(line => !line.Contains(encodedScript.Substring(0, Math.Min(20, encodedScript.Length))))
|
||||
.ToList();
|
||||
|
||||
var output = string.Join(Environment.NewLine, filteredLines).Trim();
|
||||
|
||||
lock (_errorBuffer)
|
||||
{
|
||||
var error = _errorBuffer.ToString().Trim();
|
||||
_errorBuffer.Clear();
|
||||
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
throw new InvalidOperationException($"PowerShell error: {error}\nScript:\n{script}");
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the PowerShell session.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (_process != null)
|
||||
{
|
||||
// Unregister event handlers
|
||||
_process.OutputDataReceived -= OnOutputDataReceived;
|
||||
_process.ErrorDataReceived -= OnErrorDataReceived;
|
||||
|
||||
if (!_process.HasExited)
|
||||
{
|
||||
try
|
||||
{
|
||||
_process.StandardInput.WriteLine("EXIT_SESSION");
|
||||
if (!_process.WaitForExit(5000))
|
||||
{
|
||||
_process.Kill();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors during cleanup
|
||||
try { _process.Kill(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
_process.Dispose();
|
||||
_process = null;
|
||||
}
|
||||
|
||||
_outputAvailable.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Windows implementation of <see cref="IDiskImageHelper"/> using PowerShell and VHD files.
|
||||
/// </summary>
|
||||
internal class WindowsDiskImageHelper : IDiskImageHelper
|
||||
{
|
||||
// Static PowerShell session that persists across method calls
|
||||
private static PowerShellSession? _session;
|
||||
private static readonly object _sessionLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the shared PowerShell session, creating it if necessary.
|
||||
/// </summary>
|
||||
/// <returns>The shared <see cref="PowerShellSession"/> instance.</returns>
|
||||
private static PowerShellSession GetSession()
|
||||
{
|
||||
lock (_sessionLock)
|
||||
{
|
||||
if (_session == null)
|
||||
{
|
||||
_session = new PowerShellSession();
|
||||
}
|
||||
return _session;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a PowerShell script using the persistent session.
|
||||
/// </summary>
|
||||
/// <param name="script">The PowerShell script to execute.</param>
|
||||
/// <param name="timeoutSeconds">The timeout in seconds for the script execution.</param>
|
||||
/// <returns>The output from PowerShell.</returns>
|
||||
private static string RunPowerShell(string script, int timeoutSeconds = 30)
|
||||
{
|
||||
var session = GetSession();
|
||||
return session.ExecuteScript(script, timeoutSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the disk number from a disk identifier string (e.g., "\\.\PhysicalDrive2" -> 2).
|
||||
/// </summary>
|
||||
/// <param name="diskIdentifier">The disk identifier string.</param>
|
||||
/// <returns>The disk number.</returns>
|
||||
private static int ParseDiskNumber(string diskIdentifier)
|
||||
{
|
||||
const string prefix = @"\\.\PhysicalDrive";
|
||||
if (diskIdentifier.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& int.TryParse(diskIdentifier.Substring(prefix.Length), out int num))
|
||||
return num;
|
||||
|
||||
throw new ArgumentException($"Invalid disk identifier: {diskIdentifier}. Expected format: \\\\.\\PhysicalDriveN", nameof(diskIdentifier));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the disk number for a VHD image file.
|
||||
/// </summary>
|
||||
/// <param name="imagePath">The path to the VHD file.</param>
|
||||
/// <returns>The disk number, or -1 if not found.</returns>
|
||||
private static int GetDiskNumber(string imagePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var script = $@"
|
||||
$image = Get-DiskImage -ImagePath '{imagePath}'
|
||||
if ($image -and $image.Attached) {{
|
||||
$image | Get-Disk | Select-Object -ExpandProperty Number
|
||||
}}
|
||||
";
|
||||
var result = RunPowerShell(script)?.Trim();
|
||||
|
||||
if (!string.IsNullOrEmpty(result) && int.TryParse(result, out int diskNumber))
|
||||
return diskNumber;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: PowerShell GetDiskNumber failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the drive letters (mount points) for all partitions on a disk.
|
||||
/// </summary>
|
||||
/// <param name="diskNumber">The disk number.</param>
|
||||
/// <returns>An array of mount point paths (e.g., "E:\").</returns>
|
||||
private static string[] GetMountPoints(int diskNumber)
|
||||
{
|
||||
var script = $@"
|
||||
Get-Partition -DiskNumber {diskNumber} |
|
||||
Where-Object {{ $_.DriveLetter -and $_.DriveLetter -ne [char]0 }} |
|
||||
ForEach-Object {{ $_.DriveLetter }}
|
||||
";
|
||||
var result = RunPowerShell(script)?.Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(result))
|
||||
return [];
|
||||
|
||||
return result
|
||||
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(l => l.Trim())
|
||||
.Where(l => l.Length == 1 && char.IsLetter(l[0]))
|
||||
.Select(l => $"{char.ToUpperInvariant(l[0])}:\\")
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CreateDisk(string imagePath, long sizeB)
|
||||
{
|
||||
// Ensure the directory exists
|
||||
var directory = Path.GetDirectoryName(imagePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
// Delete existing VHD if it exists
|
||||
if (File.Exists(imagePath))
|
||||
try
|
||||
{
|
||||
CleanupDisk(imagePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
|
||||
// Create the VHD using PowerShell
|
||||
// Windows GPT initialization adds 16 MB of overhead, which means that the partitions may not have enough disk towards the end.
|
||||
var overheadBytes = 16 * 1024 * 1024;
|
||||
var script = $@"New-VHD -Path '{imagePath}' -SizeBytes {sizeB + overheadBytes} -Fixed | Out-Null; Mount-DiskImage -ImagePath '{imagePath}' | Out-Null";
|
||||
RunPowerShell(script);
|
||||
|
||||
// Wait for the disk to be attached and get the disk number
|
||||
int diskNumber = WaitForDiskAttachment(imagePath, TimeSpan.FromSeconds(5));
|
||||
if (diskNumber < 0)
|
||||
throw new InvalidOperationException($"Failed to get disk number for VHD: {imagePath}");
|
||||
|
||||
return $@"\\.\PhysicalDrive{diskNumber}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a VHD to be attached and returns its disk number.
|
||||
/// </summary>
|
||||
/// <param name="imagePath">The path to the VHD file.</param>
|
||||
/// <param name="timeout">Maximum time to wait.</param>
|
||||
/// <returns>The disk number, or -1 if not found within the timeout.</returns>
|
||||
private static int WaitForDiskAttachment(string imagePath, TimeSpan timeout)
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
while (DateTime.UtcNow - startTime < timeout)
|
||||
{
|
||||
var diskNumber = GetDiskNumber(imagePath);
|
||||
if (diskNumber >= 0)
|
||||
return diskNumber;
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string[] InitializeDisk(string diskIdentifier, PartitionTableType tableType, (FileSystemType, long)[] partitions)
|
||||
{
|
||||
if (tableType == PartitionTableType.Unknown)
|
||||
return []; // No partition table, so nothing to initialize
|
||||
|
||||
var diskNumber = ParseDiskNumber(diskIdentifier);
|
||||
|
||||
// Initialize the disk with the specified partition style
|
||||
var script = $@"
|
||||
$disk = Get-Disk -Number {diskNumber}
|
||||
if ($disk.PartitionStyle -ne 'RAW') {{
|
||||
Clear-Disk -Number {diskNumber} -RemoveData -Confirm:$false
|
||||
}}
|
||||
Initialize-Disk -Number {diskNumber} -PartitionStyle {tableType.ToString().ToUpperInvariant()} -PassThru | Out-Null
|
||||
";
|
||||
RunPowerShell(script);
|
||||
|
||||
// Wait for the initialization to complete
|
||||
WaitForDiskInitialization(diskNumber, TimeSpan.FromSeconds(5));
|
||||
|
||||
if (partitions.Length == 0)
|
||||
return [];
|
||||
|
||||
// Create and format each partition
|
||||
foreach (var (fsType, sizeB) in partitions)
|
||||
{
|
||||
var fsTypeStr = fsType.ToString().ToUpperInvariant();
|
||||
var sizeParam = sizeB > 0 ? $"-Size {sizeB}" : "-UseMaximumSize";
|
||||
|
||||
script = $@"
|
||||
$partition = New-Partition -DiskNumber {diskNumber} {sizeParam} -AssignDriveLetter
|
||||
# Wait for the partition to be ready
|
||||
$timeout = (Get-Date).AddSeconds(30)
|
||||
while ((Get-Date) -lt $timeout) {{
|
||||
$vol = Get-Partition -DiskNumber {diskNumber} | Where-Object {{ $_.PartitionNumber -eq $partition.PartitionNumber }} | Get-Volume
|
||||
if ($vol) {{ break }}
|
||||
Start-Sleep -Milliseconds 100
|
||||
}}
|
||||
# Format the volume
|
||||
Format-Volume -Partition $partition -FileSystem {fsTypeStr} -NewFileSystemLabel 'TestVol' -Confirm:$false | Out-Null
|
||||
";
|
||||
RunPowerShell(script);
|
||||
}
|
||||
|
||||
return GetMountPoints(diskNumber);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a disk to be initialized.
|
||||
/// </summary>
|
||||
/// <param name="diskNumber">The disk number.</param>
|
||||
/// <param name="timeout">Maximum time to wait.</param>
|
||||
/// <exception cref="TimeoutException">Thrown if initialization times out.</exception>
|
||||
private static void WaitForDiskInitialization(int diskNumber, TimeSpan timeout)
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
while (DateTime.UtcNow - startTime < timeout)
|
||||
{
|
||||
var script = $"(Get-Disk -Number {diskNumber}).PartitionStyle";
|
||||
var result = RunPowerShell(script)?.Trim();
|
||||
if (result == "GPT" || result == "MBR")
|
||||
return;
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
throw new TimeoutException($"Disk {diskNumber} initialization timed out");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string[] Mount(string diskIdentifier, string? baseMountPath = null, bool readOnly = false, FileSystemType[]? fileSystemTypes = null)
|
||||
{
|
||||
var diskNumber = ParseDiskNumber(diskIdentifier);
|
||||
|
||||
// Check if already mounted
|
||||
try
|
||||
{
|
||||
var mountPoints = GetMountPoints(diskNumber);
|
||||
if (mountPoints.Length > 0)
|
||||
return mountPoints;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors and try mounting
|
||||
}
|
||||
|
||||
// Bring the disk online and assign drive letters
|
||||
var script = $@"
|
||||
Set-Disk -Number {diskNumber} -IsOffline $false
|
||||
Update-Disk -Number {diskNumber}
|
||||
Update-HostStorageCache
|
||||
Get-Partition -DiskNumber {diskNumber} | Where-Object {{ $_.Type -ne 'Reserved' -and $_.Type -ne 'System' -and -not $_.DriveLetter }} | ForEach-Object {{
|
||||
$_ | Add-PartitionAccessPath -AssignDriveLetter
|
||||
}}
|
||||
";
|
||||
RunPowerShell(script);
|
||||
|
||||
return WaitForMountPoints(diskNumber);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for mount points to be fully established after a mount operation.
|
||||
/// Uses exponential backoff starting from a short delay to minimize wait time
|
||||
/// in the common case while still handling slow mounts.
|
||||
/// </summary>
|
||||
/// <param name="diskNumber">The disk number to check mount points for.</param>
|
||||
/// <returns>An array of mount point paths.</returns>
|
||||
private static string[] WaitForMountPoints(int diskNumber)
|
||||
{
|
||||
const int maxRetries = 2;
|
||||
|
||||
for (int i = 0; i < maxRetries; i++)
|
||||
{
|
||||
var mountPoints = GetMountPoints(diskNumber);
|
||||
|
||||
if (mountPoints.Length > 0)
|
||||
{
|
||||
// Check if all mount points have filesystem entries (not empty)
|
||||
var allNonEmpty = mountPoints.All(mp =>
|
||||
{
|
||||
try { return Directory.GetFileSystemEntries(mp).Length > 0; }
|
||||
catch { return false; }
|
||||
});
|
||||
|
||||
if (allNonEmpty || i == maxRetries - 1)
|
||||
return mountPoints;
|
||||
}
|
||||
}
|
||||
|
||||
// Final attempt - return whatever we have
|
||||
return GetMountPoints(diskNumber);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Unmount(string diskIdentifier)
|
||||
{
|
||||
var diskNumber = ParseDiskNumber(diskIdentifier);
|
||||
|
||||
// Remove drive letters from all partitions
|
||||
var script = $@"
|
||||
Get-Partition -DiskNumber {diskNumber} |
|
||||
Where-Object {{ $_.DriveLetter -and $_.DriveLetter -ne [char]0 }} |
|
||||
ForEach-Object {{
|
||||
$letter = $_.DriveLetter
|
||||
Remove-PartitionAccessPath -DiskNumber {diskNumber} -PartitionNumber $_.PartitionNumber -AccessPath ""$($letter):\""
|
||||
}}
|
||||
";
|
||||
RunPowerShell(script);
|
||||
|
||||
// Pull the disk offline to ensure it's not in use
|
||||
script = $@"
|
||||
Set-Disk -Number {diskNumber} -IsOffline $true
|
||||
Set-Disk -Number {diskNumber} -IsReadOnly $false
|
||||
";
|
||||
RunPowerShell(script);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void FlushDisk(string diskIdentifier)
|
||||
{
|
||||
var diskNumber = ParseDiskNumber(diskIdentifier);
|
||||
try
|
||||
{
|
||||
// Flush all volumes on the disk
|
||||
var script = $@"
|
||||
Get-Partition -DiskNumber {diskNumber} |
|
||||
Where-Object {{ $_.DriveLetter -and $_.DriveLetter -ne [char]0 }} |
|
||||
ForEach-Object {{
|
||||
Write-VolumeCache -DriveLetter $_.DriveLetter
|
||||
}}
|
||||
";
|
||||
RunPowerShell(script);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to flush volume cache: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CleanupDisk(string imagePath, string? diskIdentifier = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var script = $@"
|
||||
$image = Get-DiskImage -ImagePath '{imagePath}'
|
||||
if ($image -and $image.Attached) {{
|
||||
Dismount-DiskImage -ImagePath '{imagePath}'
|
||||
}}
|
||||
";
|
||||
RunPowerShell(script);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to detach disk image: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(imagePath))
|
||||
File.Delete(imagePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to delete disk image file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasRequiredPrivileges()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var principal = new WindowsPrincipal(identity);
|
||||
return principal.IsInRole(WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PartitionTableGeometry GetPartitionTable(string diskIdentifier)
|
||||
{
|
||||
var diskNumber = ParseDiskNumber(diskIdentifier);
|
||||
|
||||
var script = $@"
|
||||
$disk = Get-Disk -Number {diskNumber}
|
||||
Write-Output ""PartitionStyle:$($disk.PartitionStyle)""
|
||||
Write-Output ""Size:$($disk.Size)""
|
||||
Write-Output ""LogicalSectorSize:$($disk.LogicalSectorSize)""
|
||||
";
|
||||
var output = RunPowerShell(script);
|
||||
|
||||
PartitionTableType tableType = PartitionTableType.Unknown;
|
||||
long size = -1;
|
||||
int sectorSize = -1;
|
||||
|
||||
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var parts = line.Split([':'], 2);
|
||||
if (parts.Length != 2) continue;
|
||||
|
||||
switch (parts[0].Trim())
|
||||
{
|
||||
case "PartitionStyle":
|
||||
tableType = parts[1].Trim() switch
|
||||
{
|
||||
"GPT" => PartitionTableType.GPT,
|
||||
"MBR" => PartitionTableType.MBR,
|
||||
_ => PartitionTableType.Unknown
|
||||
};
|
||||
break;
|
||||
case "Size":
|
||||
if (long.TryParse(parts[1].Trim(), out long parsedSize))
|
||||
size = parsedSize;
|
||||
break;
|
||||
case "LogicalSectorSize":
|
||||
if (int.TryParse(parts[1].Trim(), out int parsedSectorSize))
|
||||
sectorSize = parsedSectorSize;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (size == -1 || sectorSize == -1)
|
||||
throw new InvalidOperationException($"Failed to retrieve partition table information for disk {diskIdentifier}");
|
||||
|
||||
return new PartitionTableGeometry
|
||||
{
|
||||
Type = tableType,
|
||||
Size = size,
|
||||
SectorSize = sectorSize
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PartitionGeometry[] GetPartitions(string diskIdentifier)
|
||||
{
|
||||
var diskNumber = ParseDiskNumber(diskIdentifier);
|
||||
|
||||
var script = $@"
|
||||
Get-Partition -DiskNumber {diskNumber} |
|
||||
Where-Object {{ $_.Type -ne 'Reserved' -and $_.Type -ne 'System' }} |
|
||||
ForEach-Object {{
|
||||
$vol = $_ | Get-Volume
|
||||
Write-Output ""---PARTITION---""
|
||||
Write-Output ""Number:$($_.PartitionNumber)""
|
||||
Write-Output ""Type:$($_.Type)""
|
||||
Write-Output ""Offset:$($_.Offset)""
|
||||
Write-Output ""Size:$($_.Size)""
|
||||
Write-Output ""GptType:$($_.GptType)""
|
||||
Write-Output ""Guid:$($_.Guid)""
|
||||
if ($vol) {{
|
||||
Write-Output ""FileSystem:$($vol.FileSystem)""
|
||||
Write-Output ""FileSystemLabel:$($vol.FileSystemLabel)""
|
||||
}}
|
||||
}}
|
||||
";
|
||||
var output = RunPowerShell(script);
|
||||
|
||||
var partitions = new List<PartitionGeometry>();
|
||||
PartitionGeometry? current = null;
|
||||
|
||||
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (line.Trim() == "---PARTITION---")
|
||||
{
|
||||
if (current != null)
|
||||
partitions.Add(current);
|
||||
current = new PartitionGeometry
|
||||
{
|
||||
Number = -1,
|
||||
Type = PartitionType.Unknown,
|
||||
StartOffset = -1,
|
||||
Size = -1,
|
||||
Name = null,
|
||||
FilesystemType = FileSystemType.Unknown,
|
||||
VolumeGuid = null,
|
||||
TableType = PartitionTableType.Unknown
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current == null) continue;
|
||||
|
||||
var parts = line.Split([':'], 2);
|
||||
if (parts.Length != 2) continue;
|
||||
|
||||
switch (parts[0].Trim())
|
||||
{
|
||||
case "Number":
|
||||
if (int.TryParse(parts[1].Trim(), out int num))
|
||||
current.Number = num;
|
||||
break;
|
||||
case "Type":
|
||||
current.Type = parts[1].Trim() switch
|
||||
{
|
||||
"Basic" => PartitionType.Primary,
|
||||
"IFS" => PartitionType.Primary,
|
||||
_ => PartitionType.Unknown
|
||||
};
|
||||
break;
|
||||
case "Offset":
|
||||
if (long.TryParse(parts[1].Trim(), out long offset))
|
||||
current.StartOffset = offset;
|
||||
break;
|
||||
case "Size":
|
||||
if (long.TryParse(parts[1].Trim(), out long size))
|
||||
current.Size = size;
|
||||
break;
|
||||
case "Guid":
|
||||
if (Guid.TryParse(parts[1].Trim(), out Guid guid))
|
||||
current.VolumeGuid = guid;
|
||||
break;
|
||||
case "FileSystem":
|
||||
current.FilesystemType = parts[1].Trim() switch
|
||||
{
|
||||
"NTFS" => FileSystemType.NTFS,
|
||||
"FAT32" => FileSystemType.FAT32,
|
||||
"exFAT" => FileSystemType.ExFAT,
|
||||
"FAT" => FileSystemType.FAT32,
|
||||
"ReFS" => FileSystemType.ReFS,
|
||||
_ => FileSystemType.Unknown
|
||||
};
|
||||
break;
|
||||
case "FileSystemLabel":
|
||||
current.Name = parts[1].Trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (current != null)
|
||||
partitions.Add(current);
|
||||
|
||||
return partitions.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ReAttach(string imagePath, string diskIdentifier, PartitionTableType tableType, bool readOnly = false)
|
||||
{
|
||||
// Unmount the disk first to ensure it's in a clean state
|
||||
Unmount(diskIdentifier);
|
||||
|
||||
// Flush any pending writes and pull the disk offline
|
||||
FlushDisk(diskIdentifier);
|
||||
|
||||
// Re-attach the disk image with the desired read-only state
|
||||
var script = $@"
|
||||
$image = Get-DiskImage -ImagePath '{imagePath}'
|
||||
if ($image) {{
|
||||
$disk = $image | Get-Disk
|
||||
# Pull disk online to repopulate Windows cache
|
||||
if ($disk.IsOffline -Eq $true) {{
|
||||
$disk | Set-Disk -IsOffline $false
|
||||
}}
|
||||
if ($image.Attached) {{
|
||||
Dismount-DiskImage -ImagePath '{imagePath}'
|
||||
}}
|
||||
Mount-VHD -Path '{imagePath}' {(readOnly ? "-ReadOnly" : "")}
|
||||
}} else {{
|
||||
throw 'Disk image not found: {imagePath}'
|
||||
}}
|
||||
";
|
||||
var output = RunPowerShell(script, 300);
|
||||
|
||||
// Wait for the disk to be attached and get the new disk number
|
||||
int newDiskNumber = WaitForDiskAttachment(imagePath, TimeSpan.FromSeconds(5));
|
||||
if (newDiskNumber < 0)
|
||||
throw new InvalidOperationException($"Failed to re-attach VHD: {imagePath}");
|
||||
|
||||
return $@"\\.\PhysicalDrive{newDiskNumber}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_sessionLock)
|
||||
{
|
||||
_session?.Dispose();
|
||||
_session = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,113 +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 Duplicati.Proprietary.DiskImage;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for disk image helper operations that work across different operating systems.
|
||||
/// Provides methods for creating, attaching, formatting, and managing virtual disk images
|
||||
/// for testing disk image backup and restore operations.
|
||||
/// </summary>
|
||||
public interface IDiskImageHelper : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a virtual disk file, attaches it to the system, and returns the physical drive path.
|
||||
/// </summary>
|
||||
/// <param name="imagePath">The path where the disk image file will be created.</param>
|
||||
/// <param name="sizeB">The size of the disk image in bytes.</param>
|
||||
/// <returns>The physical drive path (e.g., \\.\PhysicalDriveN on Windows, /dev/loopN on Linux).</returns>
|
||||
string CreateDisk(string imagePath, long sizeB);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes, partitions, and formats a disk based on the provided parameters
|
||||
/// </summary>
|
||||
/// <param name="diskIdentifier">The identifier of the disk to initialize.</param>
|
||||
/// <param name="tableType">The type of partition table to create (e.g., GPT, MBR).</param>
|
||||
/// <param name="partitions">An array of tuples specifying the file system type and size for each partition. If the size is 0, the partition will use the remaining available space.</param>
|
||||
/// <returns>An array of mount points for the created partitions.</returns>
|
||||
string[] InitializeDisk(string diskIdentifier, PartitionTableType tableType, (FileSystemType, long)[] partitions);
|
||||
|
||||
/// <summary>
|
||||
/// Mounts all of the partitions on the specified disk and returns their mount points.
|
||||
/// </summary>
|
||||
/// <param name="diskIdentifier">The identifier of the disk to mount.</param>
|
||||
/// <param name="baseMountPath">Optional base path for mounting the partitions.</param>
|
||||
/// <param name="readOnly">Indicates whether the partitions should be mounted as read-only.</param>
|
||||
/// <param name="fileSystemTypes">Optional array of file system types for the partitions.</param>
|
||||
/// <returns>An array of mount points for the mounted partitions.</returns>
|
||||
string[] Mount(string diskIdentifier, string? baseMountPath = null, bool readOnly = false, FileSystemType[]? fileSystemTypes = null);
|
||||
|
||||
/// <summary>
|
||||
/// Unmounts all of the partitions on the specified disk, ensuring they are safely detached from the system. The disk will still be attached, but will be pulled offline. After this call, the disk device can be written to.
|
||||
/// </summary>
|
||||
/// <param name="diskIdentifier">The identifier of the disk to unmount.</param>
|
||||
void Unmount(string diskIdentifier);
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up and deletes a disk image file, ensuring it is detached first.
|
||||
/// </summary>
|
||||
/// <param name="imagePath">The path to the disk image file.</param>
|
||||
/// <param name="diskIdentifier">The identifier of the disk to clean up. If null, the method will attempt to determine the disk based on the image path.</param>
|
||||
void CleanupDisk(string imagePath, string? diskIdentifier = null);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the current process has the necessary privileges to perform disk operations.
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> if running with sufficient privileges; otherwise, <c>false</c>.</returns>
|
||||
bool HasRequiredPrivileges();
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the partition table information for a given disk identifier.
|
||||
/// </summary>
|
||||
/// <param name="diskIdentifier">The identifier of the disk.</param>
|
||||
/// <returns>The partition table geometry of the disk.</returns>
|
||||
PartitionTableGeometry GetPartitionTable(string diskIdentifier);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the partition information for a given disk identifier.
|
||||
/// </summary>
|
||||
/// <param name="diskIdentifier">The identifier of the disk.</param>
|
||||
/// <returns>An array of partition geometries for the disk.</returns>
|
||||
PartitionGeometry[] GetPartitions(string diskIdentifier);
|
||||
|
||||
/// <summary>
|
||||
/// Flushes the specified disk. Important prior to reading the disk raw.
|
||||
/// </summary>
|
||||
/// <param name="diskIdentifier">The identifier of the disk to flush.</param>
|
||||
void FlushDisk(string diskIdentifier);
|
||||
|
||||
/// <summary>
|
||||
/// Re-attaches the disk, optionally as read-only. This is useful for ensuring the disk is in a clean state after unmounting, and can also be used to force a read-only re-attachment for testing read-only scenarios.
|
||||
/// </summary>
|
||||
/// <param name="imagePath">The path to the disk image file.</param>
|
||||
/// <param name="diskIdentifier">The identifier of the disk to re-attach.</param>
|
||||
/// <param name="tableType">The type of partition table on the disk.</param>
|
||||
/// <param name="readOnly">Indicates whether the disk should be re-attached as read-only.</param>
|
||||
/// <returns>The identifier of the re-attached disk.</returns>
|
||||
string ReAttach(string imagePath, string diskIdentifier, PartitionTableType tableType, bool readOnly = false);
|
||||
}
|
||||
}
|
||||
@@ -1,759 +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.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using Duplicati.Proprietary.DiskImage;
|
||||
using NUnit.Framework;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage
|
||||
{
|
||||
/// <summary>
|
||||
/// Linux implementation of <see cref="IDiskImageHelper"/> using loop devices and standard Linux tools.
|
||||
/// </summary>
|
||||
internal class LinuxDiskImageHelper : IDiskImageHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks loop devices created by this helper for cleanup.
|
||||
/// </summary>
|
||||
private readonly List<string> _trackedLoopDevices = new();
|
||||
|
||||
/// <summary>
|
||||
/// Maps image paths to their associated loop devices.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, string> _imageToLoopDevice = new();
|
||||
|
||||
/// <summary>
|
||||
/// Tracks any mount directories that were automatically created for cleanup.
|
||||
/// </summary>
|
||||
private readonly List<string> _autoCreatedMntDirectories = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CreateDisk(string imagePath, long sizeB)
|
||||
{
|
||||
// Ensure the directory exists
|
||||
var directory = Path.GetDirectoryName(imagePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
// Delete existing image if it exists
|
||||
if (File.Exists(imagePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
CleanupDisk(imagePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Create a sparse disk image file using truncate
|
||||
RunProcess("truncate", $"-s {sizeB} \"{imagePath}\"");
|
||||
|
||||
// Attach it as a loop device
|
||||
var loopDevice = RunProcess("losetup", $"--find --show --partscan \"{imagePath}\"").Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(loopDevice) || !loopDevice.StartsWith("/dev/loop"))
|
||||
throw new InvalidOperationException($"Failed to attach loop device for {imagePath}");
|
||||
|
||||
// Track the loop device
|
||||
_trackedLoopDevices.Add(loopDevice);
|
||||
_imageToLoopDevice[imagePath] = loopDevice;
|
||||
|
||||
return loopDevice;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string[] InitializeDisk(string diskIdentifier, PartitionTableType tableType, (FileSystemType, long)[] partitions)
|
||||
{
|
||||
if (tableType == PartitionTableType.Unknown)
|
||||
return []; // No partition table, so nothing to initialize
|
||||
|
||||
// Create partition table using parted
|
||||
var label = tableType == PartitionTableType.GPT ? "gpt" : "msdos";
|
||||
RunProcess("parted", $"-s {diskIdentifier} mklabel {label}");
|
||||
|
||||
// Get disk size for calculating partition sizes
|
||||
var diskSize = GetDiskSize(diskIdentifier);
|
||||
var sectorSize = GetSectorSize(diskIdentifier);
|
||||
var mbrSize = 1 * sectorSize; // MBR requires 1 sector for the partition table
|
||||
var gptSize = mbrSize + 33 * sectorSize; // GPT requires 34 sectors reserved at the beginning (1 for protective MBR + 33 for GPT header and partition entries)
|
||||
long nextFreeSector = tableType == PartitionTableType.GPT ? gptSize : mbrSize;
|
||||
|
||||
// Create partitions
|
||||
for (int i = 0; i < partitions.Length; i++)
|
||||
{
|
||||
var (fsType, size) = partitions[i];
|
||||
var partitionNumber = i + 1;
|
||||
|
||||
// Calculate partition size
|
||||
long endOffset;
|
||||
if (size <= 0)
|
||||
{
|
||||
if (i != partitions.Length - 1)
|
||||
throw new InvalidOperationException("Only the last partition can be sized to fill the remaining space");
|
||||
endOffset = diskSize - sectorSize;
|
||||
}
|
||||
else
|
||||
endOffset = nextFreeSector + size - sectorSize;
|
||||
|
||||
// Check if we should clamp
|
||||
if (tableType == PartitionTableType.GPT && endOffset > diskSize - gptSize)
|
||||
if (size <= 0)
|
||||
endOffset = diskSize - gptSize;
|
||||
else
|
||||
throw new InvalidOperationException($"Partition {partitionNumber} exceeds available space. Max size for this partition is {diskSize - gptSize - nextFreeSector} bytes.");
|
||||
|
||||
// Create partition
|
||||
RunProcess("parted", $"-s {diskIdentifier} mkpart primary {nextFreeSector}B {endOffset}B");
|
||||
|
||||
// For MBR, set the boot flag on the first partition (some filesystems need this)
|
||||
if (tableType == PartitionTableType.MBR && i == 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
RunProcess("parted", $"-s {diskIdentifier} set {partitionNumber} boot on");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors setting boot flag
|
||||
}
|
||||
}
|
||||
|
||||
nextFreeSector = endOffset + sectorSize;
|
||||
}
|
||||
|
||||
// Re-read partition table
|
||||
RunProcess("partprobe", $"{diskIdentifier}");
|
||||
|
||||
// Small delay to allow kernel to create partition devices
|
||||
System.Threading.Thread.Sleep(200);
|
||||
|
||||
// Format each partition
|
||||
for (int i = 0; i < partitions.Length; i++)
|
||||
{
|
||||
var (fsType, _) = partitions[i];
|
||||
var partitionNumber = i + 1;
|
||||
var partitionDevice = GetPartitionDevice(diskIdentifier, partitionNumber);
|
||||
|
||||
// Wait for partition device to exist
|
||||
var retryCount = 0;
|
||||
while (!File.Exists(partitionDevice) && retryCount < 10)
|
||||
{
|
||||
System.Threading.Thread.Sleep(100);
|
||||
retryCount++;
|
||||
}
|
||||
|
||||
if (!File.Exists(partitionDevice))
|
||||
throw new InvalidOperationException($"Partition device {partitionDevice} did not appear after creation");
|
||||
|
||||
// Format the partition
|
||||
FormatPartition(partitionDevice, fsType);
|
||||
}
|
||||
|
||||
return partitions.Length == 0 ? [] : Mount(diskIdentifier, null, false, null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string[] Mount(string diskIdentifier, string? baseMountPath = null, bool readOnly = false, FileSystemType[]? fileSystemTypes = null)
|
||||
{
|
||||
var mountPoints = new List<string>();
|
||||
var partitions = GetPartitionNames(diskIdentifier);
|
||||
|
||||
if (partitions.Count == 0)
|
||||
throw new InvalidOperationException($"No partitions found for disk {diskIdentifier}");
|
||||
if (fileSystemTypes is not null && fileSystemTypes.Length != partitions.Count)
|
||||
throw new ArgumentException($"Length of fileSystemTypes array ({fileSystemTypes.Length}) must match the number of partitions ({partitions.Count})");
|
||||
|
||||
for (int i = 0; i < partitions.Count; i++)
|
||||
{
|
||||
var partitionDevice = partitions[i];
|
||||
|
||||
// Get filesystem type to handle special cases
|
||||
FileSystemType? fsType = null;
|
||||
if (fileSystemTypes is not null)
|
||||
fsType = fileSystemTypes[i];
|
||||
|
||||
// Create mount point
|
||||
string mountPoint;
|
||||
if (!string.IsNullOrEmpty(baseMountPath))
|
||||
{
|
||||
var partitionName = Path.GetFileName(partitionDevice);
|
||||
mountPoint = Path.Combine(baseMountPath, partitionName);
|
||||
}
|
||||
else
|
||||
{
|
||||
mountPoint = $"/mnt/duplicati_{Path.GetFileName(partitionDevice)}_{Guid.NewGuid():N}";
|
||||
_autoCreatedMntDirectories.Add(mountPoint);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(mountPoint);
|
||||
|
||||
// Mount the partition
|
||||
List<string> options = [];
|
||||
if (readOnly)
|
||||
options.Add("ro");
|
||||
if (fsType == FileSystemType.XFS)
|
||||
options.Add("nouuid"); // XFS does not allow mounting with duplicate UUIDs, so we ignore UUIDs for testing
|
||||
var optionsArg = options.Count > 0 ? $"-o {string.Join(",", options)}" : "";
|
||||
var mountArgs = $"{optionsArg} {partitionDevice} \"{mountPoint}\"".Trim();
|
||||
RunProcess("mount", mountArgs);
|
||||
|
||||
mountPoints.Add(mountPoint);
|
||||
}
|
||||
|
||||
return mountPoints.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Unmount(string diskIdentifier)
|
||||
{
|
||||
// Find all mounted partitions of this device
|
||||
var partitions = GetPartitionNames(diskIdentifier);
|
||||
|
||||
foreach (var partitionDevice in partitions)
|
||||
{
|
||||
// Check if mounted
|
||||
var mountInfo = GetMountPoint(partitionDevice);
|
||||
if (!string.IsNullOrEmpty(mountInfo))
|
||||
{
|
||||
RunProcess("umount", partitionDevice);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CleanupDisk(string imagePath, string? diskIdentifier = null)
|
||||
{
|
||||
// Determine the loop device if not provided
|
||||
if (string.IsNullOrEmpty(diskIdentifier))
|
||||
{
|
||||
if (_imageToLoopDevice.TryGetValue(imagePath, out var trackedDevice))
|
||||
{
|
||||
diskIdentifier = trackedDevice;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try to find the loop device using losetup -j
|
||||
try
|
||||
{
|
||||
var output = RunProcess("losetup", $"-j \"{imagePath}\"");
|
||||
if (!string.IsNullOrEmpty(output))
|
||||
{
|
||||
// Output format: /dev/loop0: [0005]:123456 (/path/to/image)
|
||||
var match = Regex.Match(output, @"^(\S+):");
|
||||
if (match.Success)
|
||||
diskIdentifier = match.Groups[1].Value;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unmount partitions
|
||||
if (!string.IsNullOrEmpty(diskIdentifier))
|
||||
{
|
||||
bool unmounted = false;
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
Unmount(diskIdentifier);
|
||||
unmounted = true;
|
||||
break;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Try to wait
|
||||
Thread.Sleep(1000);
|
||||
}
|
||||
}
|
||||
if (!unmounted)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to unmount partitions for {diskIdentifier} after multiple attempts.");
|
||||
}
|
||||
|
||||
// Detach loop device
|
||||
try
|
||||
{
|
||||
RunProcess("losetup", $"-d {diskIdentifier}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to detach loop device {diskIdentifier}: {ex.Message}");
|
||||
}
|
||||
|
||||
_trackedLoopDevices.Remove(diskIdentifier);
|
||||
}
|
||||
|
||||
// Delete the image file
|
||||
if (File.Exists(imagePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(imagePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to delete image file {imagePath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
_imageToLoopDevice.Remove(imagePath);
|
||||
|
||||
// Clean up any auto-created mount directories
|
||||
foreach (var mntDir in _autoCreatedMntDirectories)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(mntDir))
|
||||
Directory.Delete(mntDir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to delete mount directory {mntDir}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
_autoCreatedMntDirectories.Clear();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasRequiredPrivileges()
|
||||
{
|
||||
// Check if running as root (uid 0)
|
||||
try
|
||||
{
|
||||
var uid = RunProcess("id", "-u").Trim();
|
||||
return uid == "0";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PartitionTableGeometry GetPartitionTable(string diskIdentifier)
|
||||
{
|
||||
var deviceName = Path.GetFileName(diskIdentifier);
|
||||
var tableType = PartitionTableType.Unknown;
|
||||
|
||||
// Use blkid to get partition table type
|
||||
try
|
||||
{
|
||||
var output = RunProcess("blkid", $"-p -o value -s PTTYPE {diskIdentifier}").Trim();
|
||||
tableType = output.ToLower() switch
|
||||
{
|
||||
"gpt" => PartitionTableType.GPT,
|
||||
"dos" => PartitionTableType.MBR,
|
||||
"msdos" => PartitionTableType.MBR,
|
||||
_ => PartitionTableType.Unknown
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
var size = GetDiskSize(diskIdentifier);
|
||||
var sectorSize = GetSectorSize(diskIdentifier);
|
||||
|
||||
return new PartitionTableGeometry
|
||||
{
|
||||
Type = tableType,
|
||||
Size = size,
|
||||
SectorSize = sectorSize
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PartitionGeometry[] GetPartitions(string diskIdentifier)
|
||||
{
|
||||
var partitions = new List<PartitionGeometry>();
|
||||
var partitionNames = GetPartitionNames(diskIdentifier);
|
||||
|
||||
for (int i = 0; i < partitionNames.Count; i++)
|
||||
{
|
||||
var partitionDevice = partitionNames[i];
|
||||
var partitionNumber = i + 1;
|
||||
|
||||
try
|
||||
{
|
||||
// Get partition info using blockdev
|
||||
var start = 0L;
|
||||
var size = 0L;
|
||||
|
||||
try
|
||||
{
|
||||
var devName = Path.GetFileName(partitionDevice);
|
||||
var startFile = $"/sys/class/block/{devName}/start";
|
||||
if (File.Exists(startFile))
|
||||
{
|
||||
var startStr = File.ReadAllText(startFile).Trim();
|
||||
if (long.TryParse(startStr, out var startSector))
|
||||
{
|
||||
start = startSector * 512;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var startStr = RunProcess("blockdev", $"--getstart {partitionDevice}").Trim();
|
||||
if (long.TryParse(startStr, out var startSector))
|
||||
{
|
||||
// blockdev --getstart always returns the start sector in 512-byte units
|
||||
start = startSector * 512;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to get start for {partitionDevice}: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
size = GetDiskSize(partitionDevice);
|
||||
}
|
||||
catch { }
|
||||
|
||||
// Get filesystem type
|
||||
var fsType = FileSystemType.Unknown;
|
||||
try
|
||||
{
|
||||
var fsStr = RunProcess("blkid", $"-o value -s TYPE {partitionDevice}").Trim();
|
||||
fsType = ParseFilesystemType(fsStr);
|
||||
}
|
||||
catch { }
|
||||
|
||||
partitions.Add(new PartitionGeometry
|
||||
{
|
||||
Number = partitionNumber,
|
||||
Type = PartitionType.Unknown,
|
||||
StartOffset = start,
|
||||
Size = size,
|
||||
FilesystemType = fsType,
|
||||
TableType = PartitionTableType.Unknown
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to get info for partition {partitionDevice}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return partitions.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void FlushDisk(string diskIdentifier)
|
||||
{
|
||||
// Sync all filesystems
|
||||
RunProcess("sync", "");
|
||||
|
||||
// Flush buffers for the specific device
|
||||
try
|
||||
{
|
||||
RunProcess("blockdev", $"--flushbufs {diskIdentifier}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ReAttach(string imagePath, string diskIdentifier, PartitionTableType tableType, bool readOnly = false)
|
||||
{
|
||||
// Unmount and detach
|
||||
try
|
||||
{
|
||||
Unmount(diskIdentifier);
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
RunProcess("losetup", $"-d {diskIdentifier}");
|
||||
}
|
||||
catch { }
|
||||
|
||||
// Re-attach the loop device
|
||||
var readOnlyArg = readOnly ? "--read-only" : "";
|
||||
var newLoopDevice = RunProcess("losetup", $"--find --show --partscan {readOnlyArg} \"{imagePath}\"").Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(newLoopDevice) || !newLoopDevice.StartsWith("/dev/loop"))
|
||||
throw new InvalidOperationException($"Failed to re-attach loop device for {imagePath}");
|
||||
|
||||
// Update tracking
|
||||
_trackedLoopDevices.Remove(diskIdentifier);
|
||||
_trackedLoopDevices.Add(newLoopDevice);
|
||||
_imageToLoopDevice[imagePath] = newLoopDevice;
|
||||
|
||||
// Allow time for partition discovery
|
||||
System.Threading.Thread.Sleep(200);
|
||||
|
||||
return newLoopDevice;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
// Clean up any remaining tracked loop devices
|
||||
foreach (var loopDevice in _trackedLoopDevices.ToList())
|
||||
{
|
||||
try
|
||||
{
|
||||
// Find associated image
|
||||
var imagePath = _imageToLoopDevice.FirstOrDefault(x => x.Value == loopDevice).Key;
|
||||
if (!string.IsNullOrEmpty(imagePath))
|
||||
{
|
||||
CleanupDisk(imagePath, loopDevice);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Just try to detach the loop device
|
||||
RunProcess("losetup", $"-d {loopDevice}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to dispose loop device {loopDevice}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
_trackedLoopDevices.Clear();
|
||||
_imageToLoopDevice.Clear();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Runs a process and returns the output.
|
||||
/// </summary>
|
||||
private static string RunProcess(string fileName, string arguments)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
Arguments = arguments,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
process.Start();
|
||||
string output = process.StandardOutput.ReadToEnd();
|
||||
string error = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Process {fileName} exited with code {process.ExitCode}: {error}");
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the partition device path for a given disk and partition number.
|
||||
/// </summary>
|
||||
private static string GetPartitionDevice(string diskIdentifier, int partitionNumber)
|
||||
{
|
||||
// For loop devices, partitions are named /dev/loop0p1, /dev/loop0p2, etc.
|
||||
if (diskIdentifier.StartsWith("/dev/loop"))
|
||||
{
|
||||
return $"{diskIdentifier}p{partitionNumber}";
|
||||
}
|
||||
|
||||
// For NVMe devices, partitions are named /dev/nvme0n1p1, etc.
|
||||
if (diskIdentifier.Contains("nvme"))
|
||||
{
|
||||
return $"{diskIdentifier}p{partitionNumber}";
|
||||
}
|
||||
|
||||
// For standard SCSI/SATA devices, partitions are named /dev/sda1, /dev/sda2, etc.
|
||||
return $"{diskIdentifier}{partitionNumber}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of partition device paths for a disk.
|
||||
/// </summary>
|
||||
private static List<string> GetPartitionNames(string diskIdentifier)
|
||||
{
|
||||
var partitions = new List<string>();
|
||||
var deviceName = Path.GetFileName(diskIdentifier);
|
||||
|
||||
// Check for partition devices
|
||||
if (diskIdentifier.StartsWith("/dev/loop"))
|
||||
{
|
||||
// Loop device partitions: /dev/loop0p1, /dev/loop0p2, etc.
|
||||
for (int i = 1; i <= 16; i++)
|
||||
{
|
||||
var partitionDevice = $"{diskIdentifier}p{i}";
|
||||
if (File.Exists(partitionDevice))
|
||||
partitions.Add(partitionDevice);
|
||||
else if (i > 1)
|
||||
break; // Stop after first missing partition
|
||||
}
|
||||
}
|
||||
else if (diskIdentifier.Contains("nvme"))
|
||||
{
|
||||
// NVMe partitions: /dev/nvme0n1p1, etc.
|
||||
for (int i = 1; i <= 16; i++)
|
||||
{
|
||||
var partitionDevice = $"{diskIdentifier}p{i}";
|
||||
if (File.Exists(partitionDevice))
|
||||
partitions.Add(partitionDevice);
|
||||
else if (i > 1)
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standard partitions: /dev/sda1, /dev/sda2, etc.
|
||||
for (int i = 1; i <= 16; i++)
|
||||
{
|
||||
var partitionDevice = $"{diskIdentifier}{i}";
|
||||
if (File.Exists(partitionDevice))
|
||||
partitions.Add(partitionDevice);
|
||||
else if (i > 1)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return partitions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the mount point for a partition device.
|
||||
/// </summary>
|
||||
private static string? GetMountPoint(string partitionDevice)
|
||||
{
|
||||
try
|
||||
{
|
||||
var output = RunProcess("findmnt", $"--raw --noheadings -o TARGET {partitionDevice}").Trim();
|
||||
return string.IsNullOrEmpty(output) ? null : output;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the filesystem type of a partition.
|
||||
/// </summary>
|
||||
private static string GetFilesystemType(string partitionDevice)
|
||||
{
|
||||
try
|
||||
{
|
||||
return RunProcess("blkid", $"-o value -s TYPE {partitionDevice}").Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a partition with the specified filesystem type.
|
||||
/// </summary>
|
||||
private static void FormatPartition(string partitionDevice, FileSystemType fsType)
|
||||
{
|
||||
var (command, args) = fsType switch
|
||||
{
|
||||
FileSystemType.FAT12 => ("mkfs.vfat", $"-F 12 {partitionDevice}"),
|
||||
FileSystemType.FAT16 => ("mkfs.vfat", $"-F 16 {partitionDevice}"),
|
||||
FileSystemType.FAT32 => ("mkfs.vfat", $"-F 32 {partitionDevice}"),
|
||||
FileSystemType.ExFAT => ("mkfs.exfat", $"{partitionDevice}"),
|
||||
FileSystemType.Ext2 => ("mkfs.ext2", $"-F {partitionDevice}"),
|
||||
FileSystemType.Ext3 => ("mkfs.ext3", $"-F {partitionDevice}"),
|
||||
FileSystemType.Ext4 => ("mkfs.ext4", $"-F {partitionDevice}"),
|
||||
FileSystemType.XFS => ("mkfs.xfs", $"-f {partitionDevice}"),
|
||||
FileSystemType.Btrfs => ("mkfs.btrfs", $"-f {partitionDevice}"),
|
||||
FileSystemType.NTFS => ("mkfs.ntfs", $"-F {partitionDevice}"),
|
||||
FileSystemType.ZFS => throw new NotSupportedException("ZFS is not supported for testing. ZFS requires pool creation which is incompatible with standard partition-based testing."),
|
||||
_ => throw new NotSupportedException($"Unsupported filesystem type on Linux: {fsType}")
|
||||
};
|
||||
|
||||
RunProcess(command, args);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the size of a disk or partition in bytes.
|
||||
/// </summary>
|
||||
private static long GetDiskSize(string devicePath)
|
||||
{
|
||||
var output = RunProcess("blockdev", $"--getsize64 {devicePath}").Trim();
|
||||
return long.TryParse(output, out var size) ? size : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the sector size of a disk.
|
||||
/// </summary>
|
||||
private static int GetSectorSize(string devicePath)
|
||||
{
|
||||
var output = RunProcess("blockdev", $"--getss {devicePath}").Trim();
|
||||
return int.TryParse(output, out var size) ? size : 512;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a filesystem type string to the FileSystemType enum.
|
||||
/// </summary>
|
||||
private static FileSystemType ParseFilesystemType(string fsType)
|
||||
{
|
||||
return fsType.ToLower() switch
|
||||
{
|
||||
"ntfs" => FileSystemType.NTFS,
|
||||
"vfat" or "fat" => FileSystemType.FAT32, // Could be FAT12/16/32
|
||||
"exfat" => FileSystemType.ExFAT,
|
||||
"hfsplus" or "hfs+" => FileSystemType.HFSPlus,
|
||||
"apfs" => FileSystemType.APFS,
|
||||
"ext2" => FileSystemType.Ext2,
|
||||
"ext3" => FileSystemType.Ext3,
|
||||
"ext4" => FileSystemType.Ext4,
|
||||
"xfs" => FileSystemType.XFS,
|
||||
"btrfs" => FileSystemType.Btrfs,
|
||||
"zfs" => FileSystemType.ZFS,
|
||||
"refs" => FileSystemType.ReFS,
|
||||
_ => FileSystemType.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,674 +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.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Xml.Linq;
|
||||
using Duplicati.Library.Utility;
|
||||
using Duplicati.Proprietary.DiskImage;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
using NUnit.Framework;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage
|
||||
{
|
||||
/// <summary>
|
||||
/// macOS implementation of <see cref="IDiskImageHelper"/> using hdiutil and standard macOS tools.
|
||||
/// </summary>
|
||||
internal class MacOSDiskImageHelper : IDiskImageHelper
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CreateDisk(string imagePath, long sizeB)
|
||||
{
|
||||
// Ensure the directory exists
|
||||
var directory = Path.GetDirectoryName(imagePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
// Delete existing image if it exists
|
||||
if (File.Exists(imagePath))
|
||||
try
|
||||
{
|
||||
CleanupDisk(imagePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
|
||||
// Create a raw disk image using hdiutil
|
||||
// Use UDIF format with "Free Space" filesystem (unformatted)
|
||||
RunProcess("hdiutil", $"create -size {sizeB} -type UDIF -layout NONE -o \"{imagePath}\"");
|
||||
|
||||
var output = RunProcess("hdiutil", $"attach -nomount -noautofsck \"{imagePath}\"");
|
||||
|
||||
// Parse the disk device from output (e.g., "/dev/disk4")
|
||||
var diskDevice = output.Trim().Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim()
|
||||
?? throw new InvalidOperationException("Failed to get disk device from hdiutil attach output");
|
||||
|
||||
// Return the raw device path for direct I/O
|
||||
return diskDevice.Replace("/dev/disk", "/dev/rdisk");
|
||||
}
|
||||
|
||||
private string[] GetPartitionNames(string diskIdentifier)
|
||||
{
|
||||
var output = RunProcess("diskutil", $"list -plist {diskIdentifier}");
|
||||
|
||||
var plist = XDocument.Parse(output);
|
||||
var rootDict = plist.Element("plist")?.Element("dict");
|
||||
if (rootDict == null)
|
||||
return [];
|
||||
|
||||
// Find the disk in AllDisksAndPartitions
|
||||
var allDisksAndPartitions = PlistHelper.GetArrayElement(rootDict, "AllDisksAndPartitions");
|
||||
if (allDisksAndPartitions == null)
|
||||
return [];
|
||||
|
||||
// Normalize disk identifier - plist stores just "disk4" but we might have "/dev/disk4" or "/dev/rdisk4"
|
||||
var normalizedId = diskIdentifier.Replace("/dev/rdisk", "disk").Replace("/dev/disk", "disk");
|
||||
|
||||
var diskElement = allDisksAndPartitions.Elements("dict")
|
||||
.FirstOrDefault(d => PlistHelper.GetStringValue(d, "DeviceIdentifier") == normalizedId);
|
||||
|
||||
if (diskElement == null)
|
||||
return [];
|
||||
|
||||
var partitions = new List<string>();
|
||||
|
||||
// Check for regular partitions
|
||||
var partitionsArray = PlistHelper.GetArrayElement(diskElement, "Partitions");
|
||||
if (partitionsArray != null)
|
||||
{
|
||||
foreach (var partition in partitionsArray.Elements("dict"))
|
||||
{
|
||||
var deviceIdentifier = PlistHelper.GetStringValue(partition, "DeviceIdentifier");
|
||||
if (!string.IsNullOrWhiteSpace(deviceIdentifier))
|
||||
partitions.Add(deviceIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for APFS volumes
|
||||
var apfsVolumes = PlistHelper.GetArrayElement(diskElement, "APFSVolumes");
|
||||
if (apfsVolumes != null)
|
||||
{
|
||||
foreach (var volume in apfsVolumes.Elements("dict"))
|
||||
{
|
||||
var deviceIdentifier = PlistHelper.GetStringValue(volume, "DeviceIdentifier");
|
||||
if (!string.IsNullOrWhiteSpace(deviceIdentifier))
|
||||
partitions.Add(deviceIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
return [.. partitions];
|
||||
}
|
||||
|
||||
private string[] GetMountPoints(string diskIdentifier)
|
||||
{
|
||||
var partitions = GetPartitionNames(diskIdentifier);
|
||||
var mountPoints = new System.Collections.Generic.List<string>();
|
||||
|
||||
foreach (var partition in partitions)
|
||||
{
|
||||
var output = RunProcess("diskutil", $"info -plist /dev/{partition}");
|
||||
var plist = XDocument.Parse(output);
|
||||
var dict = plist.Element("plist")?.Element("dict");
|
||||
if (dict == null)
|
||||
continue;
|
||||
|
||||
// Check if this is an APFS Physical Store
|
||||
var contentType = PlistHelper.GetStringValue(dict, "Content");
|
||||
var isApfsPhysicalStore = contentType == "Apple_APFS";
|
||||
|
||||
if (isApfsPhysicalStore)
|
||||
{
|
||||
// For APFS, get the container and then get volumes from it
|
||||
var containerId = PlistHelper.GetStringValue(dict, "APFSContainerReference");
|
||||
if (!string.IsNullOrEmpty(containerId))
|
||||
{
|
||||
// Get volumes from the container (they are like disk5s1, disk5s2, etc.)
|
||||
var containerVolumes = GetApfsContainerVolumes(containerId);
|
||||
foreach (var volume in containerVolumes)
|
||||
{
|
||||
var volumeOutput = RunProcess("diskutil", $"info -plist {volume}");
|
||||
var volumePlist = XDocument.Parse(volumeOutput);
|
||||
var volumeDict = volumePlist.Element("plist")?.Element("dict");
|
||||
if (volumeDict != null)
|
||||
{
|
||||
var mountPoint = PlistHelper.GetStringValue(volumeDict, "MountPoint");
|
||||
if (!string.IsNullOrEmpty(mountPoint))
|
||||
mountPoints.Add(mountPoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Regular partition - extract mount point directly
|
||||
var mountPoint = PlistHelper.GetStringValue(dict, "MountPoint");
|
||||
if (!string.IsNullOrEmpty(mountPoint))
|
||||
mountPoints.Add(mountPoint);
|
||||
}
|
||||
}
|
||||
|
||||
return [.. mountPoints];
|
||||
}
|
||||
|
||||
private string[] GetApfsContainerVolumes(string containerId)
|
||||
{
|
||||
var output = RunProcess("diskutil", $"list -plist {containerId}");
|
||||
|
||||
var plist = XDocument.Parse(output);
|
||||
var rootDict = plist.Element("plist")?.Element("dict");
|
||||
if (rootDict == null)
|
||||
return [];
|
||||
|
||||
var allDisksAndPartitions = PlistHelper.GetArrayElement(rootDict, "AllDisksAndPartitions");
|
||||
if (allDisksAndPartitions == null)
|
||||
return [];
|
||||
|
||||
// Normalize container id - plist stores just "disk4" but we might have "/dev/disk4" or "/dev/rdisk4"
|
||||
var normalizedId = containerId.Replace("/dev/rdisk", "disk").Replace("/dev/disk", "disk");
|
||||
|
||||
var container = allDisksAndPartitions.Elements("dict")
|
||||
.FirstOrDefault(d => PlistHelper.GetStringValue(d, "DeviceIdentifier") == normalizedId);
|
||||
|
||||
if (container == null)
|
||||
return [];
|
||||
|
||||
var volumes = new List<string>();
|
||||
var apfsVolumes = PlistHelper.GetArrayElement(container, "APFSVolumes");
|
||||
if (apfsVolumes != null)
|
||||
{
|
||||
foreach (var volume in apfsVolumes.Elements("dict"))
|
||||
{
|
||||
var deviceIdentifier = PlistHelper.GetStringValue(volume, "DeviceIdentifier");
|
||||
if (!string.IsNullOrWhiteSpace(deviceIdentifier))
|
||||
volumes.Add(deviceIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
return [.. volumes];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string[] InitializeDisk(string diskIdentifier, PartitionTableType tableType, (FileSystemType, long)[] partitions)
|
||||
{
|
||||
if (tableType == PartitionTableType.Unknown)
|
||||
return []; // No partition table, so nothing to initialize
|
||||
|
||||
// Use diskutil to partition the disk with the specified scheme
|
||||
// "Free Space" means no filesystem, just the partition table
|
||||
var partitionStrings = partitions.Length > 0 ? partitions.Select((p, i) =>
|
||||
{
|
||||
var fsType = p.Item1 switch
|
||||
{
|
||||
FileSystemType.NTFS => throw new NotSupportedException("NTFS is not natively supported on macOS for creating partitions. Use ExFAT or FAT32 instead."),
|
||||
FileSystemType.FAT32 => "\"MS-DOS FAT32\"",
|
||||
FileSystemType.ExFAT => "ExFAT",
|
||||
FileSystemType.HFSPlus => "HFS+",
|
||||
FileSystemType.APFS => "APFS",
|
||||
_ => throw new ArgumentException($"Unsupported filesystem type on macOS: {p.Item1}", nameof(partitions))
|
||||
};
|
||||
|
||||
var sizeArg = p.Item2 > 0 ? $"{p.Item2}" : "0";
|
||||
return $"{fsType} \"Partition {i + 1}\" {sizeArg}";
|
||||
})
|
||||
: ["\"Free Space\" \"\" 0"];
|
||||
var partitionArgs = string.Join(" ", partitionStrings);
|
||||
|
||||
// Normalize disk identifier for diskutil command (it expects "disk4", not "/dev/rdisk4")
|
||||
var normalizedDiskId = diskIdentifier.Replace("/dev/rdisk", "disk").Replace("/dev/disk", "disk");
|
||||
RunProcess("diskutil", $"partitionDisk {normalizedDiskId} {tableType.ToString().ToUpperInvariant()} {partitionArgs}");
|
||||
|
||||
if (partitions.Length == 0)
|
||||
return [];
|
||||
|
||||
return GetMountPoints(diskIdentifier);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string[] Mount(string diskIdentifier, string? baseMountPath = null, bool readOnly = false, FileSystemType[]? fileSystemTypes = null)
|
||||
{
|
||||
// Check if already mounted
|
||||
try
|
||||
{
|
||||
var existingMountPoints = GetMountPoints(diskIdentifier);
|
||||
if (existingMountPoints.Length > 0 && existingMountPoints.All(mp => mp.StartsWith(baseMountPath ?? "/Volumes/")))
|
||||
{
|
||||
return existingMountPoints;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors and try mounting
|
||||
}
|
||||
|
||||
// Get the list of partitions first to ensure we target the correct disk
|
||||
var partitions = GetPartitionNames(diskIdentifier);
|
||||
|
||||
List<string> mountPoints = [];
|
||||
foreach (var partition in partitions)
|
||||
{
|
||||
var output = RunProcess("diskutil", $"info -plist /dev/{partition}");
|
||||
var plist = XDocument.Parse(output);
|
||||
var dict = plist.Element("plist")?.Element("dict");
|
||||
if (dict == null)
|
||||
continue;
|
||||
|
||||
// Check if this is an APFS Physical Store
|
||||
var contentType = PlistHelper.GetStringValue(dict, "Content");
|
||||
var isApfsPhysicalStore = contentType == "Apple_APFS";
|
||||
|
||||
if (isApfsPhysicalStore)
|
||||
{
|
||||
// For APFS, get the container and mount volumes from it
|
||||
var containerId = PlistHelper.GetStringValue(dict, "APFSContainerReference");
|
||||
if (!string.IsNullOrEmpty(containerId))
|
||||
{
|
||||
// Get volumes from the container (they are like disk5s1, disk5s2, etc.)
|
||||
var containerVolumes = GetApfsContainerVolumes(containerId);
|
||||
foreach (var volume in containerVolumes)
|
||||
{
|
||||
MountVolume(volume, baseMountPath, mountPoints, readOnly);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Regular partition - mount directly
|
||||
MountVolume(partition, baseMountPath, mountPoints, readOnly);
|
||||
}
|
||||
}
|
||||
|
||||
return mountPoints.Count == 0 ? WaitForMountPoints(diskIdentifier) : [.. mountPoints];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mounts a single volume/partition with optional custom mount point.
|
||||
/// </summary>
|
||||
/// <param name="volume">The volume identifier (e.g., "disk4s1").</param>
|
||||
/// <param name="baseMountPath">Optional base path for mounting.</param>
|
||||
/// <param name="mountPoints">List to collect mount points.</param>
|
||||
/// <param name="readOnly">Indicates whether the volume should be mounted as read-only.</param>
|
||||
private void MountVolume(string volume, string? baseMountPath, List<string> mountPoints, bool readOnly)
|
||||
{
|
||||
var dir = "";
|
||||
var mountArgs = readOnly ? "-readOnly" : "";
|
||||
if (baseMountPath is not null)
|
||||
{
|
||||
dir = Path.Combine(baseMountPath, volume);
|
||||
Directory.CreateDirectory(dir); // diskutil will mount to this directory instead of /Volumes/ if it exists, so create it ahead of time
|
||||
var readOnlyArg = readOnly ? " -readOnly" : "";
|
||||
mountArgs = $"{readOnlyArg} -mountPoint \"{dir}\"";
|
||||
}
|
||||
|
||||
// Unmount first in case it's already mounted somewhere else, to ensure it gets mounted to the correct location
|
||||
var oldMp = GetMountPointFromPlist(RunProcess("diskutil", $"info -plist /dev/{volume}"));
|
||||
if (oldMp is not null && !oldMp.StartsWith(baseMountPath ?? "/Volumes/"))
|
||||
RunProcess("diskutil", $"unmount force /dev/{volume}");
|
||||
|
||||
RunProcess("diskutil", $"mount -nobrowse {mountArgs} /dev/{volume}");
|
||||
|
||||
// Verify the mount
|
||||
var mp = RunProcess("diskutil", $"info -plist /dev/{volume}");
|
||||
var mountPoint = GetMountPointFromPlist(mp);
|
||||
if (!string.IsNullOrEmpty(mountPoint))
|
||||
{
|
||||
mountPoints.Add(mountPoint);
|
||||
|
||||
if (!mountPoint.StartsWith(baseMountPath ?? "/Volumes/"))
|
||||
{
|
||||
Console.WriteLine($"Warning: Volume /dev/{volume} did not mount to expected location. Output: {mp}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Warning: Volume /dev/{volume} did not report a mount point after mounting. Output: {mp}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the mount point from diskutil info -plist output.
|
||||
/// </summary>
|
||||
private string? GetMountPointFromPlist(string plistOutput)
|
||||
{
|
||||
try
|
||||
{
|
||||
var plist = XDocument.Parse(plistOutput);
|
||||
var dict = plist.Element("plist")?.Element("dict");
|
||||
if (dict == null)
|
||||
return null;
|
||||
|
||||
var mountPoint = PlistHelper.GetStringValue(dict, "MountPoint");
|
||||
// Return null if not mounted (empty string indicates not mounted)
|
||||
return string.IsNullOrWhiteSpace(mountPoint) ? null : mountPoint;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for mount points to be fully established after a mount operation.
|
||||
/// Uses exponential backoff starting from a short delay to minimize wait time
|
||||
/// in the common case while still handling slow mounts.
|
||||
///
|
||||
/// After mounting (especially after raw partition table writes during restore),
|
||||
/// macOS may need time to fully recognize filesystems and establish mount points
|
||||
/// in /Volumes/. Without this retry loop, mount points may be:
|
||||
/// - Not yet available (empty array)
|
||||
/// - In a transient location like /private/var/ instead of /Volumes/
|
||||
/// - Mounted but with empty directories (filesystem not yet recognized)
|
||||
/// </summary>
|
||||
/// <param name="diskIdentifier">The disk identifier to check mount points for.</param>
|
||||
/// <returns>An array of mount point paths.</returns>
|
||||
private string[] WaitForMountPoints(string diskIdentifier)
|
||||
{
|
||||
const int maxRetries = 10;
|
||||
const int initialDelayMs = 100;
|
||||
const int maxDelayMs = 1000;
|
||||
|
||||
var delayMs = initialDelayMs;
|
||||
for (int i = 0; i < maxRetries; i++)
|
||||
{
|
||||
var mountPoints = GetMountPoints(diskIdentifier);
|
||||
|
||||
if (mountPoints.Length > 0)
|
||||
{
|
||||
// Check if any mount points are in a transient location (not under /Volumes/)
|
||||
if (mountPoints.Any(mp => !mp.StartsWith("/Volumes/")))
|
||||
{
|
||||
Thread.Sleep(delayMs);
|
||||
delayMs = Math.Min(delayMs * 2, maxDelayMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if all mount points have filesystem entries (not empty)
|
||||
var allNonEmpty = mountPoints.All(mp =>
|
||||
{
|
||||
try { return Directory.GetFileSystemEntries(mp).Length > 0; }
|
||||
catch { return false; }
|
||||
});
|
||||
|
||||
if (allNonEmpty || i == maxRetries - 1)
|
||||
return mountPoints;
|
||||
}
|
||||
|
||||
Thread.Sleep(delayMs);
|
||||
delayMs = Math.Min(delayMs * 2, maxDelayMs);
|
||||
}
|
||||
|
||||
// Final attempt - return whatever we have
|
||||
return GetMountPoints(diskIdentifier);
|
||||
}
|
||||
|
||||
public void Unmount(string diskIdentifier)
|
||||
{
|
||||
int retryCount = 3;
|
||||
for (int i = 0; i < retryCount; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Unmount all volumes on the disk
|
||||
RunProcess("diskutil", $"unmountDisk {diskIdentifier}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Sometimes an unmount is issued too fast after writing data, which makes diskutil throw an errory.
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void FlushDisk(string diskIdentifier)
|
||||
{
|
||||
// On macOS, use sync to flush all filesystem buffers
|
||||
RunProcess("sync", "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CleanupDisk(string imagePath, string? diskIdentifier = null)
|
||||
{
|
||||
if (diskIdentifier is null)
|
||||
{
|
||||
var output = RunProcess("hdiutil", $"info");
|
||||
|
||||
diskIdentifier = output
|
||||
.Split("================================================")
|
||||
.Skip(1) // Skip the header
|
||||
.Select(d =>
|
||||
{
|
||||
var lines = d
|
||||
.Split('\n')
|
||||
.Select(l => l.Trim())
|
||||
.Where(l => l.Length > 0)
|
||||
.ToList();
|
||||
var imagePathLine = lines.FirstOrDefault(l => l.StartsWith("image-path"));
|
||||
if (imagePathLine == null)
|
||||
return null;
|
||||
|
||||
var path = imagePathLine.Split([':'], 2)[1].Trim();
|
||||
if (!string.Equals(path, imagePath, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
var deviceLine = lines.FirstOrDefault(l => l.StartsWith("/dev/"));
|
||||
if (deviceLine == null)
|
||||
return null;
|
||||
|
||||
var device = deviceLine
|
||||
.Split(['\t'], 2)[0]
|
||||
.Trim();
|
||||
|
||||
return device;
|
||||
})
|
||||
.FirstOrDefault(d => d != null);
|
||||
}
|
||||
|
||||
if (diskIdentifier is null)
|
||||
throw new InvalidOperationException($"Failed to determine disk identifier for {imagePath} during cleanup");
|
||||
|
||||
try
|
||||
{
|
||||
RunProcess("diskutil", $"eject {diskIdentifier}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: Failed to detach disk image: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(imagePath))
|
||||
File.Delete(imagePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: Failed to delete disk image file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasRequiredPrivileges()
|
||||
{
|
||||
// Root is not required for virtual disks.
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PartitionTableGeometry GetPartitionTable(string diskIdentifier)
|
||||
{
|
||||
var output = RunProcess("diskutil", $"info -plist {diskIdentifier}");
|
||||
|
||||
var plist = XDocument.Parse(output);
|
||||
var dict = plist.Element("plist")?.Element("dict");
|
||||
if (dict == null)
|
||||
throw new InvalidOperationException($"Failed to parse diskutil info plist for {diskIdentifier}");
|
||||
|
||||
// Verify disk identifier
|
||||
var deviceIdentifier = PlistHelper.GetStringValue(dict, "DeviceIdentifier");
|
||||
if (!diskIdentifier.EndsWith(deviceIdentifier ?? "", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"Disk identifier mismatch in diskutil info output: {diskIdentifier} doesn't end with {deviceIdentifier}");
|
||||
|
||||
// Determine partition table type
|
||||
var contentType = PlistHelper.GetStringValue(dict, "Content");
|
||||
var tableType = contentType switch
|
||||
{
|
||||
"GUID_partition_scheme" => PartitionTableType.GPT,
|
||||
"FDisk_partition_scheme" => PartitionTableType.MBR,
|
||||
_ => PartitionTableType.Unknown
|
||||
};
|
||||
|
||||
// Get size and sector size
|
||||
var size = PlistHelper.GetLongValue(dict, "TotalSize");
|
||||
var sectorSize = (int)PlistHelper.GetLongValue(dict, "DeviceBlockSize");
|
||||
|
||||
if (size == 0 || sectorSize == 0)
|
||||
throw new InvalidOperationException($"Failed to retrieve partition table information for disk {diskIdentifier}");
|
||||
|
||||
return new PartitionTableGeometry
|
||||
{
|
||||
Type = tableType,
|
||||
Size = size,
|
||||
SectorSize = sectorSize
|
||||
// Ignore the rest for now.
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PartitionGeometry[] GetPartitions(string diskIdentifier)
|
||||
{
|
||||
var names = GetPartitionNames(diskIdentifier);
|
||||
|
||||
var partitions = new List<PartitionGeometry>();
|
||||
foreach (var name in names)
|
||||
{
|
||||
var output = RunProcess("diskutil", $"info -plist /dev/{name}");
|
||||
|
||||
var plist = XDocument.Parse(output);
|
||||
var dict = plist.Element("plist")?.Element("dict");
|
||||
if (dict == null)
|
||||
continue;
|
||||
|
||||
// Verify device node
|
||||
var deviceNode = PlistHelper.GetStringValue(dict, "DeviceNode");
|
||||
if (!string.Equals(deviceNode, $"/dev/{name}", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"Partition identifier mismatch in diskutil info output: expected /dev/{name}, got {deviceNode}");
|
||||
|
||||
// Extract partition number from name (e.g., "disk4s1" -> 1)
|
||||
var partitionNumber = int.TryParse(
|
||||
name.LastIndexOf('s') >= 0 ? name[(name.LastIndexOf('s') + 1)..] : "",
|
||||
out int num) ? num : -1;
|
||||
|
||||
// Parse filesystem type
|
||||
var fsPersonality = PlistHelper.GetStringValue(dict, "FilesystemPersonality");
|
||||
var filesystemType = fsPersonality switch
|
||||
{
|
||||
"MS-DOS FAT32" => FileSystemType.FAT32,
|
||||
"ExFAT" => FileSystemType.ExFAT,
|
||||
"HFS+" => FileSystemType.HFSPlus,
|
||||
"APFS" => FileSystemType.APFS,
|
||||
_ => FileSystemType.Unknown
|
||||
};
|
||||
|
||||
// Parse volume UUID
|
||||
Guid? volumeGuid = null;
|
||||
var volumeUuid = PlistHelper.GetStringValue(dict, "VolumeUUID");
|
||||
if (Guid.TryParse(volumeUuid, out Guid parsedGuid))
|
||||
volumeGuid = parsedGuid;
|
||||
|
||||
PartitionGeometry partition = new()
|
||||
{
|
||||
Number = partitionNumber,
|
||||
Type = PartitionType.Unknown,
|
||||
StartOffset = PlistHelper.GetLongValue(dict, "PartitionMapPartitionOffset"),
|
||||
Size = PlistHelper.GetLongValue(dict, "TotalSize"),
|
||||
Name = PlistHelper.GetStringValue(dict, "VolumeName"),
|
||||
FilesystemType = filesystemType,
|
||||
VolumeGuid = volumeGuid,
|
||||
TableType = PartitionTableType.Unknown
|
||||
};
|
||||
|
||||
partitions.Add(partition);
|
||||
}
|
||||
|
||||
return partitions.ToArray();
|
||||
}
|
||||
|
||||
public string ReAttach(string imagePath, string diskIdentifier, PartitionTableType tableType, bool readOnly = false)
|
||||
{
|
||||
// Eject can fail if issued too fast after writing data, which makes diskutil throw an error. Retry a few times with a delay to mitigate this.
|
||||
RunProcess("diskutil", $"eject {diskIdentifier}", retryCount: 3);
|
||||
var reattachoutput = RunProcess("hdiutil", $"attach -nomount -noautofsck -readonly \"{imagePath}\"");
|
||||
if (tableType == PartitionTableType.Unknown)
|
||||
return reattachoutput.Split('\n').First().Trim();
|
||||
else
|
||||
return reattachoutput
|
||||
.Split('\n')
|
||||
.Select(x => x.Trim()
|
||||
.Split(' ')
|
||||
.Where(y => !string.IsNullOrEmpty(y))
|
||||
.Select(y => y.Trim())
|
||||
.ToArray()
|
||||
)
|
||||
.Where(x => x.Length > 0 && x[^1].EndsWith("_partition_scheme"))
|
||||
.First().First();
|
||||
}
|
||||
|
||||
public static string RunProcess(string fileName, string arguments, int retryCount = 0)
|
||||
{
|
||||
for (int i = 0; i <= retryCount; i++)
|
||||
{
|
||||
var result = ProcessRunner.RunProcessAsync(fileName, arguments, 30_000).Await();
|
||||
|
||||
|
||||
if (result.ExitCode != 0)
|
||||
if (i < retryCount)
|
||||
{
|
||||
Console.WriteLine($"Warning: Process {fileName} with arguments {arguments} exited with code {result.ExitCode}. Retrying... Attempt {i + 1} of {retryCount}");
|
||||
Thread.Sleep(500);
|
||||
continue;
|
||||
}
|
||||
else
|
||||
throw new InvalidOperationException($"Process {fileName} with arguments {arguments} exited with code {result.ExitCode}: {result.Error}. Output: {result.Output}");
|
||||
|
||||
return result.Output;
|
||||
}
|
||||
|
||||
// This should never be hit due to the retry loop logic, but is required to satisfy the compiler.
|
||||
throw new InvalidOperationException($"Failed to run process {fileName} with arguments {arguments} after retries. This should not happen.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
// Nothing to dispose
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,382 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
using StringAssert = NUnit.Framework.Legacy.StringAssert;
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.UnitTests;
|
||||
|
||||
public partial class DiskImageUnitTests : BasicSetupHelper
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Tests reading at offset 0 (first sector) returns correct data.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Test_RawDisk_ReadAtOffsetZero_ReturnsCorrectData()
|
||||
{
|
||||
var sectorSize = _writableRawDisk.SectorSize;
|
||||
|
||||
// Write known pattern to sector 0 (first sector)
|
||||
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 at offset 0
|
||||
using var stream = await _writableRawDisk.ReadBytesAsync(0, 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.");
|
||||
Assert.AreEqual(writeBuffer, readBuffer, "Data at offset 0 should match what was written.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests writing at offset 0 (first sector) works correctly.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Test_RawDisk_WriteAtOffsetZero_DataMatches()
|
||||
{
|
||||
var sectorSize = _writableRawDisk.SectorSize;
|
||||
|
||||
// Write at offset 0
|
||||
var writeBuffer = new byte[sectorSize];
|
||||
new Random(123).NextBytes(writeBuffer);
|
||||
|
||||
var bytesWritten = await _writableRawDisk.WriteBytesAsync(0, writeBuffer, CancellationToken.None);
|
||||
Assert.AreEqual(sectorSize, bytesWritten, "Should have written one sector.");
|
||||
|
||||
// Read back and verify
|
||||
using var readStream = await _writableRawDisk.ReadSectorsAsync(0, 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(writeBuffer, readBuffer, "Data at offset 0 should match.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests reading at the last sector of the disk returns correct data.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Test_RawDisk_ReadAtLastSector_ReturnsCorrectData()
|
||||
{
|
||||
var sectorSize = _writableRawDisk.SectorSize;
|
||||
var diskSize = _writableRawDisk.Size;
|
||||
var lastSector = (diskSize / sectorSize) - 1;
|
||||
|
||||
// Write known pattern to last sector
|
||||
var writeBuffer = new byte[sectorSize];
|
||||
for (int i = 0; i < sectorSize; i++)
|
||||
writeBuffer[i] = (byte)((i + 100) & 0xFF);
|
||||
|
||||
await _writableRawDisk.WriteSectorsAsync(lastSector, writeBuffer, CancellationToken.None);
|
||||
|
||||
// Read at last sector offset
|
||||
var lastSectorOffset = lastSector * sectorSize;
|
||||
using var stream = await _writableRawDisk.ReadBytesAsync(lastSectorOffset, 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.");
|
||||
Assert.AreEqual(writeBuffer, readBuffer, "Data at last sector should match.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests writing at the last sector of the disk works correctly.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Test_RawDisk_WriteAtLastSector_DataMatches()
|
||||
{
|
||||
var sectorSize = _writableRawDisk.SectorSize;
|
||||
var diskSize = _writableRawDisk.Size;
|
||||
var lastSector = (diskSize / sectorSize) - 1;
|
||||
|
||||
// Write at last sector
|
||||
var writeBuffer = new byte[sectorSize];
|
||||
new Random(456).NextBytes(writeBuffer);
|
||||
|
||||
var bytesWritten = await _writableRawDisk.WriteSectorsAsync(lastSector, writeBuffer, CancellationToken.None);
|
||||
Assert.AreEqual(sectorSize, bytesWritten, "Should have written one sector at last position.");
|
||||
|
||||
// Read 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(writeBuffer, readBuffer, "Data at last sector should match.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests reading exactly one sector returns correct data.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Test_RawDisk_ReadExactlyOneSector_ReturnsCorrectData()
|
||||
{
|
||||
var sectorSize = _writableRawDisk.SectorSize;
|
||||
|
||||
// Write known pattern to sector 5
|
||||
var writeBuffer = new byte[sectorSize];
|
||||
for (int i = 0; i < sectorSize; i++)
|
||||
writeBuffer[i] = (byte)((i * 3) & 0xFF);
|
||||
|
||||
await _writableRawDisk.WriteSectorsAsync(5, writeBuffer, CancellationToken.None);
|
||||
|
||||
// Read exactly one sector using ReadBytesAsync
|
||||
var offset = 5 * 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 exactly one sector.");
|
||||
Assert.AreEqual(sectorSize, stream.Length, "Stream length should be exactly one sector.");
|
||||
Assert.AreEqual(writeBuffer, readBuffer, "Data should match exactly.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests writing exactly one sector works correctly.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Test_RawDisk_WriteExactlyOneSector_DataMatches()
|
||||
{
|
||||
var sectorSize = _writableRawDisk.SectorSize;
|
||||
|
||||
// Write exactly one sector using WriteBytesAsync
|
||||
var writeBuffer = new byte[sectorSize];
|
||||
new Random(789).NextBytes(writeBuffer);
|
||||
|
||||
var offset = 10 * sectorSize;
|
||||
var bytesWritten = await _writableRawDisk.WriteBytesAsync(offset, writeBuffer, CancellationToken.None);
|
||||
|
||||
Assert.AreEqual(sectorSize, bytesWritten, "Should have written exactly one sector.");
|
||||
|
||||
// Read back and verify
|
||||
using var readStream = await _writableRawDisk.ReadSectorsAsync(10, 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(writeBuffer, readBuffer, "Data should match exactly.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests reading the full disk size works correctly.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Test_RawDisk_ReadFullDiskSize_ReturnsCorrectData()
|
||||
{
|
||||
// Use a smaller test disk for this test to avoid memory issues
|
||||
var sectorSize = _writableRawDisk.SectorSize;
|
||||
var diskSize = _writableRawDisk.Size;
|
||||
|
||||
// Read full disk in chunks to verify it works
|
||||
const int chunkSectors = 10;
|
||||
var chunkSize = chunkSectors * sectorSize;
|
||||
var totalSectors = diskSize / sectorSize;
|
||||
var sectorsRead = 0L;
|
||||
|
||||
for (long sector = 0; sector < totalSectors; sector += chunkSectors)
|
||||
{
|
||||
var sectorsToRead = (int)Math.Min(chunkSectors, totalSectors - sector);
|
||||
using var stream = await _writableRawDisk.ReadSectorsAsync(sector, sectorsToRead, CancellationToken.None);
|
||||
|
||||
var buffer = new byte[sectorsToRead * sectorSize];
|
||||
var bytesRead = await stream.ReadAsync(buffer.AsMemory(), CancellationToken.None);
|
||||
|
||||
Assert.AreEqual(sectorsToRead * sectorSize, bytesRead, $"Should have read {sectorsToRead} sectors at offset {sector}.");
|
||||
sectorsRead += sectorsToRead;
|
||||
}
|
||||
|
||||
Assert.AreEqual(totalSectors, sectorsRead, "Should have read all sectors.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests writing the full disk size works correctly.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Test_RawDisk_WriteFullDiskSize_DataMatches()
|
||||
{
|
||||
var sectorSize = _writableRawDisk.SectorSize;
|
||||
var diskSize = _writableRawDisk.Size;
|
||||
|
||||
// Write a unique pattern to each sector and verify
|
||||
var totalSectors = diskSize / sectorSize;
|
||||
const int testSectors = 20; // Test a subset to keep test fast
|
||||
|
||||
for (long sector = 0; sector < Math.Min(testSectors, totalSectors); sector++)
|
||||
{
|
||||
// Create unique pattern for this sector
|
||||
var writeBuffer = new byte[sectorSize];
|
||||
var patternByte = (byte)(sector & 0xFF);
|
||||
for (int i = 0; i < sectorSize; i++)
|
||||
writeBuffer[i] = (byte)((patternByte + i) & 0xFF);
|
||||
|
||||
await _writableRawDisk.WriteSectorsAsync(sector, writeBuffer, CancellationToken.None);
|
||||
|
||||
// Read back and verify
|
||||
using var readStream = await _writableRawDisk.ReadSectorsAsync(sector, 1, CancellationToken.None);
|
||||
var readBuffer = new byte[sectorSize];
|
||||
var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
|
||||
|
||||
Assert.AreEqual(sectorSize, bytesRead, $"Should read sector {sector}.");
|
||||
Assert.AreEqual(writeBuffer, readBuffer, $"Data at sector {sector} should match.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests writing empty data (0 bytes) returns 0 without error.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Test_RawDisk_WriteEmptyData_ReturnsZero()
|
||||
{
|
||||
var sectorSize = _writableRawDisk.SectorSize;
|
||||
|
||||
// Write empty data
|
||||
var emptyData = Array.Empty<byte>();
|
||||
var bytesWritten = await _writableRawDisk.WriteBytesAsync(sectorSize, emptyData, CancellationToken.None);
|
||||
|
||||
Assert.AreEqual(0, bytesWritten, "Writing empty data should return 0 bytes written.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests reading empty data (0 bytes) returns 0 without error.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Test_RawDisk_ReadEmptyData_ReturnsZero()
|
||||
{
|
||||
var sectorSize = _writableRawDisk.SectorSize;
|
||||
|
||||
// Read 0 bytes
|
||||
using var stream = await _writableRawDisk.ReadBytesAsync(sectorSize, 0, CancellationToken.None);
|
||||
var buffer = Array.Empty<byte>();
|
||||
var bytesRead = await stream.ReadAsync(buffer.AsMemory(), CancellationToken.None);
|
||||
|
||||
Assert.AreEqual(0, bytesRead, "Reading 0 bytes should return 0.");
|
||||
Assert.AreEqual(0, stream.Length, "Stream length should be 0 for empty read.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that a disk with minimum possible size still supports basic operations.
|
||||
/// Creates a small test disk and verifies read/write operations work.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Test_RawDisk_MinimumSizeDisk_OperationsWork()
|
||||
{
|
||||
// The minimum practical disk size is at least a few sectors
|
||||
// We'll use the writable disk which is already created at 100 MiB
|
||||
// but verify operations work at the boundaries
|
||||
var sectorSize = _writableRawDisk.SectorSize;
|
||||
var diskSize = _writableRawDisk.Size;
|
||||
|
||||
// Minimum operations: read/write first sector
|
||||
var firstSectorData = new byte[sectorSize];
|
||||
new Random(111).NextBytes(firstSectorData);
|
||||
await _writableRawDisk.WriteSectorsAsync(0, firstSectorData, CancellationToken.None);
|
||||
|
||||
using var firstReadStream = await _writableRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None);
|
||||
var firstReadBuffer = new byte[sectorSize];
|
||||
var firstBytesRead = await firstReadStream.ReadAsync(firstReadBuffer.AsMemory(), CancellationToken.None);
|
||||
Assert.AreEqual(sectorSize, firstBytesRead, "Should read exactly one sector.");
|
||||
Assert.AreEqual(firstSectorData, firstReadBuffer, "First sector should be readable/writable.");
|
||||
|
||||
// Minimum operations: read/write last sector
|
||||
var lastSector = (diskSize / sectorSize) - 1;
|
||||
var lastSectorData = new byte[sectorSize];
|
||||
new Random(222).NextBytes(lastSectorData);
|
||||
await _writableRawDisk.WriteSectorsAsync(lastSector, lastSectorData, CancellationToken.None);
|
||||
|
||||
using var lastReadStream = await _writableRawDisk.ReadSectorsAsync(lastSector, 1, CancellationToken.None);
|
||||
var lastReadBuffer = new byte[sectorSize];
|
||||
var lastBytesRead = await lastReadStream.ReadAsync(lastReadBuffer.AsMemory(), CancellationToken.None);
|
||||
Assert.AreEqual(sectorSize, lastBytesRead, "Should read exactly one sector.");
|
||||
Assert.AreEqual(lastSectorData, lastReadBuffer, "Last sector should be readable/writable.");
|
||||
|
||||
// Verify disk size is reported correctly
|
||||
Assert.Greater(diskSize, 0, "Disk size should be greater than 0.");
|
||||
Assert.AreEqual(0, diskSize % sectorSize, "Disk size should be sector-aligned.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests boundary condition: reading at exactly the disk size boundary should fail gracefully.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Test_RawDisk_ReadAtDiskSizeBoundary_ThrowsInvalidOperationException()
|
||||
{
|
||||
var sectorSize = _writableRawDisk.SectorSize;
|
||||
var diskSize = _writableRawDisk.Size;
|
||||
|
||||
// Attempting to read at or beyond disk size should throw InvalidOperationException
|
||||
var ex = Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
using var stream = await _writableRawDisk.ReadBytesAsync(diskSize, sectorSize, CancellationToken.None);
|
||||
var buffer = new byte[sectorSize];
|
||||
var _ = await stream.ReadAsync(buffer.AsMemory(), CancellationToken.None);
|
||||
}, "Reading at disk boundary should throw InvalidOperationException.");
|
||||
|
||||
StringAssert.Contains("beyond disk size", ex!.Message, "Exception message should mention beyond disk size.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests boundary condition: writing at exactly the disk size boundary should fail gracefully.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Test_RawDisk_WriteAtDiskSizeBoundary_ThrowsInvalidOperationException()
|
||||
{
|
||||
var sectorSize = _writableRawDisk.SectorSize;
|
||||
var diskSize = _writableRawDisk.Size;
|
||||
|
||||
// Attempting to write at or beyond disk size should throw InvalidOperationException
|
||||
var ex = Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
var writeData = new byte[sectorSize];
|
||||
await _writableRawDisk.WriteBytesAsync(diskSize, writeData, CancellationToken.None);
|
||||
}, "Writing at disk boundary should throw InvalidOperationException.");
|
||||
|
||||
StringAssert.Contains("beyond disk size", ex!.Message, "Exception message should mention beyond disk size.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests reading/writing a single byte at various boundary positions.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Test_RawDisk_SingleByteAtBoundaries_DataMatches()
|
||||
{
|
||||
var sectorSize = _writableRawDisk.SectorSize;
|
||||
|
||||
// Test positions: first byte of disk, last byte of first sector,
|
||||
// first byte of second sector, and various other positions
|
||||
var testPositions = new[]
|
||||
{
|
||||
0L, // First byte of disk
|
||||
sectorSize - 1, // Last byte of first sector
|
||||
sectorSize, // First byte of second sector
|
||||
sectorSize + 1, // Second byte of second sector
|
||||
(2 * sectorSize) - 1, // Last byte of second sector
|
||||
2 * sectorSize // First byte of third sector
|
||||
};
|
||||
|
||||
for (int i = 0; i < testPositions.Length; i++)
|
||||
{
|
||||
var position = testPositions[i];
|
||||
var testByte = (byte)(0xA0 + i);
|
||||
|
||||
// Write single byte
|
||||
var writeData = new[] { testByte };
|
||||
var bytesWritten = await _writableRawDisk.WriteBytesAsync(position, writeData, CancellationToken.None);
|
||||
Assert.AreEqual(1, bytesWritten, $"Should write 1 byte at position {position}.");
|
||||
|
||||
// Read back the byte
|
||||
using var stream = await _writableRawDisk.ReadBytesAsync(position, 1, CancellationToken.None);
|
||||
var readBuffer = new byte[1];
|
||||
var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
|
||||
|
||||
Assert.AreEqual(1, bytesRead, $"Should read 1 byte at position {position}.");
|
||||
Assert.AreEqual(testByte, readBuffer[0], $"Byte at position {position} should match.");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
using System;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.UnitTests;
|
||||
|
||||
public partial class DiskImageUnitTests : BasicSetupHelper
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Tests that a fully populated GeometryMetadata can be serialized to JSON and
|
||||
/// deserialized back, preserving all field values.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that a GeometryMetadata with null optional fields can be serialized
|
||||
/// and deserialized correctly.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that an empty GeometryMetadata (with all null/empty fields) can be
|
||||
/// serialized and deserialized correctly.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that a GeometryMetadata with multiple partitions and filesystems of
|
||||
/// different types can be serialized and deserialized correctly.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that serializing an MBR-based GeometryMetadata produces valid JSON
|
||||
/// that can be correctly deserialized.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that deserializing invalid JSON throws an appropriate exception.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Test_GeometryMetadata_FromJson_InvalidJson_ThrowsException()
|
||||
{
|
||||
// Test with invalid JSON - should throw JsonException
|
||||
var invalidJson = "{invalid json";
|
||||
Assert.Throws<System.Text.Json.JsonException>(() => GeometryMetadata.FromJson(invalidJson));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that deserializing empty JSON object creates a minimal GeometryMetadata
|
||||
/// with default property values set by the class.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
using Duplicati.Proprietary.DiskImage.Partition;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.UnitTests;
|
||||
|
||||
public partial class DiskImageUnitTests : BasicSetupHelper
|
||||
{
|
||||
[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.");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Proprietary.DiskImage.Partition;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.UnitTests;
|
||||
|
||||
public partial class DiskImageUnitTests : BasicSetupHelper
|
||||
{
|
||||
|
||||
[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<IPartition>();
|
||||
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<IPartition>();
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
using Duplicati.Proprietary.DiskImage.Partition;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.UnitTests;
|
||||
|
||||
public partial class DiskImageUnitTests : BasicSetupHelper
|
||||
{
|
||||
|
||||
[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<IPartition>();
|
||||
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<IPartition>();
|
||||
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.");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Proprietary.DiskImage.Partition;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
using StringAssert = NUnit.Framework.Legacy.StringAssert;
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.UnitTests;
|
||||
|
||||
public partial class DiskImageUnitTests : BasicSetupHelper
|
||||
{
|
||||
|
||||
[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<IOException>(() =>
|
||||
{
|
||||
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<IOException>(() =>
|
||||
{
|
||||
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<IOException>(() =>
|
||||
{
|
||||
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<IOException>(() =>
|
||||
{
|
||||
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<NotSupportedException>(() =>
|
||||
{
|
||||
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).");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.UnitTests;
|
||||
|
||||
public partial class DiskImageUnitTests : BasicSetupHelper
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Tests that PooledBuffer rents a buffer from the ArrayPool and returns it on dispose.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that PooledBuffer handles different sizes correctly.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that PooledMemoryStream reads correctly and returns buffer on dispose.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that PooledMemoryStream seek operations work correctly.
|
||||
/// </summary>
|
||||
[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).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that seeking beyond stream bounds throws ArgumentOutOfRangeException.
|
||||
/// </summary>
|
||||
[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<ArgumentOutOfRangeException>(() => stream.Seek(-1, SeekOrigin.Begin), "Seek before beginning should throw.");
|
||||
|
||||
// Seek past end
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => stream.Seek(101, SeekOrigin.Begin), "Seek past end should throw.");
|
||||
|
||||
// Seek before beginning using Current
|
||||
stream.Seek(50, SeekOrigin.Begin);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => stream.Seek(-51, SeekOrigin.Current), "Seek before beginning from current should throw.");
|
||||
|
||||
// Seek past end using End
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => stream.Seek(1, SeekOrigin.End), "Seek past end from end should throw.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that reading past the end of PooledMemoryStream returns 0 bytes.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that PooledMemoryStream returns buffer to pool on dispose.
|
||||
/// </summary>
|
||||
[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<ObjectDisposedException>(() => { 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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that PooledMemoryStream write and set length operations throw NotSupportedException.
|
||||
/// </summary>
|
||||
[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<NotSupportedException>(() => stream.Write(new byte[10], 0, 10), "Write should throw NotSupportedException.");
|
||||
|
||||
// SetLength should throw
|
||||
Assert.Throws<NotSupportedException>(() => stream.SetLength(50), "SetLength should throw NotSupportedException.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that PooledMemoryStream handles empty buffers correctly.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that PooledBuffer can be used multiple times in succession.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.UnitTests;
|
||||
|
||||
public partial class DiskImageUnitTests : BasicSetupHelper
|
||||
{
|
||||
|
||||
[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.");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.UnitTests;
|
||||
|
||||
public partial class DiskImageUnitTests : BasicSetupHelper
|
||||
{
|
||||
[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.");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.UnitTests;
|
||||
|
||||
public partial class DiskImageUnitTests : BasicSetupHelper
|
||||
{
|
||||
|
||||
[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<byte>
|
||||
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.");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
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;
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.UnitTests;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public partial class DiskImageUnitTests : BasicSetupHelper
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Creates a RestoreProvider with mock partitions and filesystems for testing path parsing.
|
||||
/// Uses reflection to set up the internal state.
|
||||
/// </summary>
|
||||
private static RestoreProvider CreateRestoreProviderForPathParsingTests(IRawDisk rawDisk)
|
||||
{
|
||||
// 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<IPartition>
|
||||
{
|
||||
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<IFilesystem>
|
||||
{
|
||||
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);
|
||||
var targetDiskField = typeof(RestoreProvider).GetField("_targetDisk", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
partitionsField?.SetValue(provider, partitions);
|
||||
filesystemsField?.SetValue(provider, filesystems);
|
||||
targetDiskField?.SetValue(provider, rawDisk);
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a RestoreProvider with mock partitions and filesystems for testing path parsing.
|
||||
/// Uses reflection to set up the internal state. Uses the GPT test disk for the device path.
|
||||
/// </summary>
|
||||
private static RestoreProvider CreateRestoreProviderForPathParsingTests()
|
||||
{
|
||||
return CreateRestoreProviderForPathParsingTests(s_gptRawDisk!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock implementation of IPartitionTable for testing.
|
||||
/// </summary>
|
||||
private class MockPartitionTable(PartitionTableType tableType) : IPartitionTable
|
||||
{
|
||||
public IRawDisk? RawDisk => null;
|
||||
public PartitionTableType TableType { get; } = tableType;
|
||||
|
||||
public IAsyncEnumerable<IPartition> EnumeratePartitions(CancellationToken cancellationToken)
|
||||
{
|
||||
return AsyncEnumerable.Empty<IPartition>();
|
||||
}
|
||||
|
||||
public Task<IPartition?> GetPartitionAsync(int partitionNumber, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IPartition?>(null);
|
||||
}
|
||||
|
||||
public Task<Stream> GetProtectiveMbrAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<Stream>(new MemoryStream());
|
||||
}
|
||||
|
||||
public Task<Stream> GetPartitionTableDataAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<Stream>(new MemoryStream());
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock implementation of IPartition for testing.
|
||||
/// </summary>
|
||||
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<Stream> OpenReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<Stream>(new MemoryStream());
|
||||
}
|
||||
|
||||
public Task<Stream> OpenWriteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<Stream>(new MemoryStream());
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock implementation of IFilesystem for testing.
|
||||
/// </summary>
|
||||
private class MockFilesystem(IPartition partition, FileSystemType type) : IFilesystem
|
||||
{
|
||||
public IPartition Partition { get; } = partition;
|
||||
public FileSystemType Type { get; } = type;
|
||||
|
||||
public Task<object?> GetFilesystemMetadataAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<IFile> ListFilesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return AsyncEnumerable.Empty<IFile>();
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<IFile> ListFilesAsync(IFile directory, CancellationToken cancellationToken)
|
||||
{
|
||||
return AsyncEnumerable.Empty<IFile>();
|
||||
}
|
||||
|
||||
public Task<Stream> OpenReadStreamAsync(IFile file, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<Stream>(new MemoryStream());
|
||||
}
|
||||
|
||||
public Task<Stream> OpenWriteStreamAsync(IFile file, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<Stream>(new MemoryStream());
|
||||
}
|
||||
|
||||
public Task<Stream> OpenReadWriteStreamAsync(IFile file, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<Stream>(new MemoryStream());
|
||||
}
|
||||
|
||||
public Task<long> GetFileLengthAsync(IFile file, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(0L);
|
||||
}
|
||||
|
||||
public Task<Stream> OpenReadStreamAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<Stream>(new MemoryStream());
|
||||
}
|
||||
|
||||
public Task<Stream> OpenWriteStreamAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<Stream>(new MemoryStream());
|
||||
}
|
||||
|
||||
public Task<Stream> OpenReadWriteStreamAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<Stream>(new MemoryStream());
|
||||
}
|
||||
|
||||
public Task<long> GetFileLengthAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(0L);
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ParsePartition correctly parses a valid GPT partition segment.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Test_RestoreProvider_ParsePartition_ValidGPTPartition_ReturnsCorrectPartition()
|
||||
{
|
||||
using var provider = CreateRestoreProviderForPathParsingTests();
|
||||
var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist.");
|
||||
|
||||
// Test parsing "part_GPT_1"
|
||||
var result = parsePartitionMethod!.Invoke(provider, ["part_GPT_1"]);
|
||||
Assert.IsNotNull(result, "Should return a partition.");
|
||||
|
||||
var partition = (IPartition)result!;
|
||||
Assert.AreEqual(1, partition.PartitionNumber, "Partition number should be 1.");
|
||||
Assert.AreEqual(PartitionTableType.GPT, partition.PartitionTable.TableType, "Partition table type should be GPT.");
|
||||
Assert.AreEqual("EFI System", partition.Name, "Partition name should match.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ParsePartition correctly parses a valid MBR partition segment.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Test_RestoreProvider_ParsePartition_ValidMBRPartition_ReturnsCorrectPartition()
|
||||
{
|
||||
using var provider = CreateRestoreProviderForPathParsingTests();
|
||||
var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist.");
|
||||
|
||||
// Test parsing "part_MBR_2"
|
||||
var result = parsePartitionMethod!.Invoke(provider, ["part_MBR_2"]);
|
||||
Assert.IsNotNull(result, "Should return a partition.");
|
||||
|
||||
var partition = (IPartition)result!;
|
||||
Assert.AreEqual(2, partition.PartitionNumber, "Partition number should be 2.");
|
||||
Assert.AreEqual(PartitionTableType.MBR, partition.PartitionTable.TableType, "Partition table type should be MBR.");
|
||||
Assert.AreEqual("MBR Partition 2", partition.Name, "Partition name should match.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ParsePartition throws InvalidOperationException for malformed partition segments.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Test_RestoreProvider_ParsePartition_MalformedSegment_ThrowsInvalidOperationException()
|
||||
{
|
||||
using var provider = CreateRestoreProviderForPathParsingTests();
|
||||
var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist.");
|
||||
|
||||
// Test with too few parts
|
||||
var ex = Assert.Throws<TargetInvocationException>(() =>
|
||||
parsePartitionMethod!.Invoke(provider, ["part_GPT"]),
|
||||
"Should throw for malformed segment.");
|
||||
Assert.IsInstanceOf<InvalidOperationException>(ex!.InnerException, "Inner exception should be InvalidOperationException.");
|
||||
|
||||
// Test with invalid partition table type
|
||||
ex = Assert.Throws<TargetInvocationException>(() =>
|
||||
parsePartitionMethod!.Invoke(provider, ["part_INVALID_1"]),
|
||||
"Should throw for invalid table type.");
|
||||
Assert.IsInstanceOf<InvalidOperationException>(ex!.InnerException, "Inner exception should be InvalidOperationException.");
|
||||
|
||||
// Test with non-numeric partition number
|
||||
ex = Assert.Throws<TargetInvocationException>(() =>
|
||||
parsePartitionMethod!.Invoke(provider, ["part_GPT_ABC"]),
|
||||
"Should throw for non-numeric partition number.");
|
||||
Assert.IsInstanceOf<InvalidOperationException>(ex!.InnerException, "Inner exception should be InvalidOperationException.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ParsePartition throws InvalidOperationException for non-existent partitions.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Test_RestoreProvider_ParsePartition_NonExistentPartition_ThrowsInvalidOperationException()
|
||||
{
|
||||
using var provider = CreateRestoreProviderForPathParsingTests();
|
||||
var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist.");
|
||||
|
||||
// Test with partition number that doesn't exist
|
||||
var ex = Assert.Throws<TargetInvocationException>(() =>
|
||||
parsePartitionMethod!.Invoke(provider, ["part_GPT_99"]),
|
||||
"Should throw for non-existent partition.");
|
||||
Assert.IsInstanceOf<InvalidOperationException>(ex!.InnerException, "Inner exception should be InvalidOperationException.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ParseFilesystem correctly parses a valid NTFS filesystem segment.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Test_RestoreProvider_ParseFilesystem_ValidNTFS_ReturnsCorrectFilesystem()
|
||||
{
|
||||
using var provider = CreateRestoreProviderForPathParsingTests();
|
||||
var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
var parseFilesystemMethod = typeof(RestoreProvider).GetMethod("ParseFilesystem", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist.");
|
||||
Assert.IsNotNull(parseFilesystemMethod, "ParseFilesystem method should exist.");
|
||||
|
||||
// Get a partition first
|
||||
var partitionResult = parsePartitionMethod!.Invoke(provider, ["part_GPT_2"]);
|
||||
Assert.IsNotNull(partitionResult, "Should return a partition.");
|
||||
var partition = (IPartition)partitionResult!;
|
||||
|
||||
// Test parsing "fs_NTFS"
|
||||
var result = parseFilesystemMethod!.Invoke(provider, [partition, "fs_NTFS"]);
|
||||
Assert.IsNotNull(result, "Should return a filesystem.");
|
||||
|
||||
var filesystem = (IFilesystem)result!;
|
||||
Assert.AreEqual(FileSystemType.NTFS, filesystem.Type, "Filesystem type should be NTFS.");
|
||||
Assert.AreEqual(partition.PartitionNumber, filesystem.Partition.PartitionNumber, "Partition number should match.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ParseFilesystem correctly parses a valid FAT32 filesystem segment.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Test_RestoreProvider_ParseFilesystem_ValidFAT32_ReturnsCorrectFilesystem()
|
||||
{
|
||||
using var provider = CreateRestoreProviderForPathParsingTests();
|
||||
var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
var parseFilesystemMethod = typeof(RestoreProvider).GetMethod("ParseFilesystem", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist.");
|
||||
Assert.IsNotNull(parseFilesystemMethod, "ParseFilesystem method should exist.");
|
||||
|
||||
// Get a partition first
|
||||
var partitionResult = parsePartitionMethod!.Invoke(provider, ["part_GPT_1"]);
|
||||
Assert.IsNotNull(partitionResult, "Should return a partition.");
|
||||
var partition = (IPartition)partitionResult!;
|
||||
|
||||
// Test parsing "fs_FAT32"
|
||||
var result = parseFilesystemMethod!.Invoke(provider, [partition, "fs_FAT32"]);
|
||||
Assert.IsNotNull(result, "Should return a filesystem.");
|
||||
|
||||
var filesystem = (IFilesystem)result!;
|
||||
Assert.AreEqual(FileSystemType.FAT32, filesystem.Type, "Filesystem type should be FAT32.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ParseFilesystem throws InvalidOperationException for malformed filesystem segments.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Test_RestoreProvider_ParseFilesystem_MalformedSegment_ThrowsInvalidOperationException()
|
||||
{
|
||||
using var provider = CreateRestoreProviderForPathParsingTests();
|
||||
var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
var parseFilesystemMethod = typeof(RestoreProvider).GetMethod("ParseFilesystem", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist.");
|
||||
Assert.IsNotNull(parseFilesystemMethod, "ParseFilesystem method should exist.");
|
||||
|
||||
// Get a partition first
|
||||
var partitionResult = parsePartitionMethod!.Invoke(provider, ["part_GPT_1"]);
|
||||
Assert.IsNotNull(partitionResult, "Should return a partition.");
|
||||
var partition = (IPartition)partitionResult!;
|
||||
|
||||
// Test with too few parts
|
||||
var ex = Assert.Throws<TargetInvocationException>(() =>
|
||||
parseFilesystemMethod!.Invoke(provider, [partition, "fs"]),
|
||||
"Should throw for malformed segment.");
|
||||
Assert.IsInstanceOf<InvalidOperationException>(ex!.InnerException, "Inner exception should be InvalidOperationException.");
|
||||
|
||||
// Test with invalid filesystem type
|
||||
ex = Assert.Throws<TargetInvocationException>(() =>
|
||||
parseFilesystemMethod!.Invoke(provider, [partition, "fs_INVALID"]),
|
||||
"Should throw for invalid filesystem type.");
|
||||
Assert.IsInstanceOf<InvalidOperationException>(ex!.InnerException, "Inner exception should be InvalidOperationException.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ParseFilesystem throws InvalidOperationException for non-existent filesystems.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Test_RestoreProvider_ParseFilesystem_NonExistentFilesystem_ThrowsInvalidOperationException()
|
||||
{
|
||||
using var provider = CreateRestoreProviderForPathParsingTests();
|
||||
var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
var parseFilesystemMethod = typeof(RestoreProvider).GetMethod("ParseFilesystem", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist.");
|
||||
Assert.IsNotNull(parseFilesystemMethod, "ParseFilesystem method should exist.");
|
||||
|
||||
// Get a partition that doesn't have an Ext4 filesystem
|
||||
var partitionResult = parsePartitionMethod!.Invoke(provider, ["part_GPT_1"]);
|
||||
Assert.IsNotNull(partitionResult, "Should return a partition.");
|
||||
var partition = (IPartition)partitionResult!;
|
||||
|
||||
// Test with filesystem type that doesn't exist for this partition
|
||||
var ex = Assert.Throws<TargetInvocationException>(() =>
|
||||
parseFilesystemMethod!.Invoke(provider, [partition, "fs_Ext4"]),
|
||||
"Should throw for non-existent filesystem.");
|
||||
Assert.IsInstanceOf<InvalidOperationException>(ex!.InnerException, "Inner exception should be InvalidOperationException.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ParsePath correctly identifies a geometry file path.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Test_RestoreProvider_ParsePath_GeometryFile_ReturnsGeometryType()
|
||||
{
|
||||
using var provider = CreateRestoreProviderForPathParsingTests();
|
||||
var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
Assert.IsNotNull(parsePathMethod, "ParsePath method should exist.");
|
||||
|
||||
// Test with geometry file at the root of the device
|
||||
// Use the actual device path from the GPT test disk
|
||||
var geometryPath = $"{s_gptRawDisk!.DevicePath}{Path.DirectorySeparatorChar}geometry.json";
|
||||
var result = parsePathMethod!.Invoke(provider, [geometryPath]);
|
||||
Assert.IsNotNull(result, "Should return a tuple.");
|
||||
|
||||
var tuple = ((string Type, IPartition? Partition, IFilesystem? Filesystem))result!;
|
||||
Assert.AreEqual("geometry", tuple.Type, "Type should be 'geometry'.");
|
||||
Assert.IsNull(tuple.Partition, "Partition should be null for geometry.");
|
||||
Assert.IsNull(tuple.Filesystem, "Filesystem should be null for geometry.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ParsePath correctly identifies a disk-level path.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Test_RestoreProvider_ParsePath_DiskPath_ReturnsDiskType()
|
||||
{
|
||||
using var provider = CreateRestoreProviderForPathParsingTests();
|
||||
var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
Assert.IsNotNull(parsePathMethod, "ParsePath method should exist.");
|
||||
|
||||
// Test with root path
|
||||
var result = parsePathMethod!.Invoke(provider, ["/"]);
|
||||
Assert.IsNotNull(result, "Should return a tuple.");
|
||||
|
||||
var tuple = ((string Type, IPartition? Partition, IFilesystem? Filesystem))result!;
|
||||
Assert.AreEqual("disk", tuple.Type, "Type should be 'disk'.");
|
||||
Assert.IsNull(tuple.Partition, "Partition should be null for disk.");
|
||||
Assert.IsNull(tuple.Filesystem, "Filesystem should be null for disk.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ParsePath correctly identifies a partition path.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Test_RestoreProvider_ParsePath_PartitionPath_ReturnsPartitionType()
|
||||
{
|
||||
using var provider = CreateRestoreProviderForPathParsingTests();
|
||||
var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
Assert.IsNotNull(parsePathMethod, "ParsePath method should exist.");
|
||||
|
||||
// Test with partition path (no filesystem segment)
|
||||
var result = parsePathMethod!.Invoke(provider, ["part_GPT_1"]);
|
||||
Assert.IsNotNull(result, "Should return a tuple.");
|
||||
|
||||
var tuple = ((string Type, IPartition? Partition, IFilesystem? Filesystem))result!;
|
||||
Assert.AreEqual("partition", tuple.Type, "Type should be 'partition'.");
|
||||
Assert.IsNotNull(tuple.Partition, "Partition should not be null.");
|
||||
Assert.AreEqual(1, tuple.Partition!.PartitionNumber, "Partition number should be 1.");
|
||||
Assert.IsNull(tuple.Filesystem, "Filesystem should be null for partition-only path.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ParsePath correctly identifies a file path with partition and filesystem.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Test_RestoreProvider_ParsePath_FilePath_ReturnsFileType()
|
||||
{
|
||||
using var provider = CreateRestoreProviderForPathParsingTests();
|
||||
var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
Assert.IsNotNull(parsePathMethod, "ParsePath method should exist.");
|
||||
|
||||
// Test with full file path
|
||||
var result = parsePathMethod!.Invoke(provider, ["part_GPT_1/fs_FAT32/test/file.txt"]);
|
||||
Assert.IsNotNull(result, "Should return a tuple.");
|
||||
|
||||
var tuple = ((string Type, IPartition? Partition, IFilesystem? Filesystem))result!;
|
||||
Assert.AreEqual("file", tuple.Type, "Type should be 'file'.");
|
||||
Assert.IsNotNull(tuple.Partition, "Partition should not be null.");
|
||||
Assert.IsNotNull(tuple.Filesystem, "Filesystem should not be null.");
|
||||
Assert.AreEqual(1, tuple.Partition!.PartitionNumber, "Partition number should be 1.");
|
||||
Assert.AreEqual(FileSystemType.FAT32, tuple.Filesystem!.Type, "Filesystem type should be FAT32.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ParsePath handles MBR partition paths correctly.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Test_RestoreProvider_ParsePath_MBRPartitionPath_ReturnsCorrectPartition()
|
||||
{
|
||||
using var provider = CreateRestoreProviderForPathParsingTests();
|
||||
var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
Assert.IsNotNull(parsePathMethod, "ParsePath method should exist.");
|
||||
|
||||
// Test with MBR partition path
|
||||
var result = parsePathMethod!.Invoke(provider, ["part_MBR_2/fs_NTFS/data/file.dat"]);
|
||||
Assert.IsNotNull(result, "Should return a tuple.");
|
||||
|
||||
var tuple = ((string Type, IPartition? Partition, IFilesystem? Filesystem))result!;
|
||||
Assert.AreEqual("file", tuple.Type, "Type should be 'file'.");
|
||||
Assert.IsNotNull(tuple.Partition, "Partition should not be null.");
|
||||
Assert.IsNotNull(tuple.Filesystem, "Filesystem should not be null.");
|
||||
Assert.AreEqual(2, tuple.Partition!.PartitionNumber, "Partition number should be 2.");
|
||||
Assert.AreEqual(PartitionTableType.MBR, tuple.Partition.PartitionTable.TableType, "Table type should be MBR.");
|
||||
Assert.AreEqual(FileSystemType.NTFS, tuple.Filesystem!.Type, "Filesystem type should be NTFS.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ParsePath handles paths with different separators correctly on Windows.
|
||||
/// On Windows, both forward slash and backslash are valid path separators.
|
||||
/// On Linux/macOS, backslash is a valid filename character, not a separator.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Test_RestoreProvider_ParsePath_DifferentSeparators_NormalizesCorrectly()
|
||||
{
|
||||
using var provider = CreateRestoreProviderForPathParsingTests();
|
||||
var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
Assert.IsNotNull(parsePathMethod, "ParsePath method should exist.");
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// On Windows, backslash is a path separator
|
||||
var result = parsePathMethod!.Invoke(provider, ["part_GPT_1\\fs_FAT32\\test\\file.txt"]);
|
||||
Assert.IsNotNull(result, "Should return a tuple.");
|
||||
|
||||
var tuple = ((string Type, IPartition? Partition, IFilesystem? Filesystem))result!;
|
||||
Assert.AreEqual("file", tuple.Type, "Type should be 'file'.");
|
||||
Assert.IsNotNull(tuple.Partition, "Partition should not be null.");
|
||||
Assert.IsNotNull(tuple.Filesystem, "Filesystem should not be null.");
|
||||
}
|
||||
else
|
||||
{
|
||||
// On Linux/macOS, backslash is a filename character, not a separator
|
||||
// The entire string becomes one segment, which fails partition parsing
|
||||
var ex = Assert.Throws<TargetInvocationException>(() =>
|
||||
parsePathMethod!.Invoke(provider, ["part_GPT_1\\fs_FAT32\\test\\file.txt"]),
|
||||
"Should throw for backslash path on non-Windows platforms.");
|
||||
Assert.IsInstanceOf<InvalidOperationException>(ex!.InnerException, "Inner exception should be InvalidOperationException.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ParsePath throws appropriate exceptions for invalid paths.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Test_RestoreProvider_ParsePath_InvalidPartitionSegment_ThrowsException()
|
||||
{
|
||||
using var provider = CreateRestoreProviderForPathParsingTests();
|
||||
var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
Assert.IsNotNull(parsePathMethod, "ParsePath method should exist.");
|
||||
|
||||
// Test with invalid partition segment format - this should still parse as file type
|
||||
// but will fail when trying to parse the partition
|
||||
var ex = Assert.Throws<TargetInvocationException>(() =>
|
||||
parsePathMethod!.Invoke(provider, ["part_INVALID/fs_NTFS/file.txt"]),
|
||||
"Should throw for invalid partition segment.");
|
||||
Assert.IsInstanceOf<InvalidOperationException>(ex!.InnerException, "Inner exception should be InvalidOperationException.");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using Duplicati.Proprietary.DiskImage.Disk;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
using Duplicati.UnitTest.DiskImage.Helpers;
|
||||
using NUnit.Framework;
|
||||
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
namespace Duplicati.UnitTest.DiskImage.UnitTests;
|
||||
|
||||
#nullable enable
|
||||
|
||||
// This class holds all of the general setup and teardown code for the DiskImage unit tests, including creating and initializing the test disk images used by multiple tests. This ensures that all tests have a consistent environment and reduces code duplication across test classes.
|
||||
|
||||
[TestFixture]
|
||||
[Category("DiskImageUnit")]
|
||||
[Platform("Win,MacOsX,Linux")]
|
||||
public partial 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;
|
||||
|
||||
/// <summary>
|
||||
/// One-time setup for the entire test class.
|
||||
/// Creates read-only GPT and MBR disks with 2 FAT32 partitions each.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-time teardown for the entire test class.
|
||||
/// Cleans up all class-level disk resources.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up the test environment before each test that needs a writable disk.
|
||||
/// </summary>
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
if (s_writableRawDisk != null)
|
||||
{
|
||||
_writableRawDisk = s_writableRawDisk;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up after each test.
|
||||
/// </summary>
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// No longer unmounting per-test to improve performance
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.UnitTests;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public partial class DiskImageUnitTests : BasicSetupHelper
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Tests that writing partial data (less than block size) is correctly flushed on dispose.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests writing at position 0, seeking to middle, writing more data, and verifying both regions.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests reading after seeking to a non-zero position.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that writing data that doesn't fill the entire stream preserves the written data
|
||||
/// while the remaining bytes retain their previous values.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the lazy-load behavior: first read triggers disk read, subsequent reads use buffer.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests seeking with SeekOrigin.Current works correctly.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests seeking with SeekOrigin.End works correctly.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that reading past the end of the stream returns 0 bytes.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that unaligned writes within the stream work correctly.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
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;
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.UnitTests;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public partial class DiskImageUnitTests : BasicSetupHelper
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ListFilesAsync returns the expected block-based files for an UnknownFilesystem.
|
||||
/// </summary>
|
||||
[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<IFile>();
|
||||
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<UnknownFilesystemFile>(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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that write and read round-trip preserves data correctly.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that BoundsCheck validates ranges correctly.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that BoundsCheck throws for negative start or size.
|
||||
/// </summary>
|
||||
[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<ArgumentOutOfRangeException>(() => fs.BoundsCheck(-1, sectorSize), "Negative start should throw.");
|
||||
|
||||
// Negative size should throw
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => fs.BoundsCheck(0, -1), "Negative size should throw.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that BoundsCheck throws for ranges exceeding partition size.
|
||||
/// </summary>
|
||||
[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<ArgumentOutOfRangeException>(() => fs.BoundsCheck(partitionSize - sectorSize, sectorSize * 2), "Range exceeding partition should throw.");
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => fs.BoundsCheck(partitionSize, sectorSize), "Start at partition end should throw.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that BoundsCheck throws for unaligned start or size.
|
||||
/// </summary>
|
||||
[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<ArgumentException>(() => fs.BoundsCheck(1, sectorSize), "Unaligned start should throw.");
|
||||
|
||||
// Unaligned size should throw
|
||||
Assert.Throws<ArgumentException>(() => fs.BoundsCheck(0, sectorSize + 1), "Unaligned size should throw.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that UnknownFilesystem constructor throws for unaligned block sizes.
|
||||
/// </summary>
|
||||
[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<ArgumentException>(() => new UnknownFilesystem(partition, blockSize: unalignedBlockSize),
|
||||
"Unaligned block size should throw ArgumentException.");
|
||||
Assert.That(ex!.ParamName, Is.EqualTo("blockSize"), "Exception should be for blockSize parameter.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that UnknownFilesystem constructor accepts valid block sizes.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper class to implement IPartition for testing.
|
||||
/// </summary>
|
||||
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<Stream> OpenReadAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromException<Stream>(new NotImplementedException());
|
||||
|
||||
public Task<Stream> OpenWriteAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromException<Stream>(new NotImplementedException());
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper class to implement IPartitionTable for testing.
|
||||
/// </summary>
|
||||
private class TestPartitionTable : IPartitionTable
|
||||
{
|
||||
public IRawDisk? RawDisk { get; init; }
|
||||
public PartitionTableType TableType => PartitionTableType.Unknown;
|
||||
public long Size => 0;
|
||||
|
||||
public IAsyncEnumerable<IPartition> EnumeratePartitions(CancellationToken cancellationToken)
|
||||
=> AsyncEnumerable.Empty<IPartition>();
|
||||
|
||||
public Task<IPartition?> GetPartitionAsync(int partitionNumber, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IPartition?>(null);
|
||||
|
||||
public Task<Stream> GetProtectiveMbrAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromException<Stream>(new NotImplementedException());
|
||||
|
||||
public Task<Stream> GetPartitionTableDataAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromException<Stream>(new NotImplementedException());
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,859 +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.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using Duplicati.Proprietary.DiskImage;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
using NUnit.Framework;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages a persistent PowerShell session for executing commands.
|
||||
/// This avoids the overhead of starting a new PowerShell process for each command.
|
||||
/// </summary>
|
||||
internal sealed class PowerShellSession : IDisposable
|
||||
{
|
||||
private Process? _process;
|
||||
private readonly object _lock = new();
|
||||
private bool _disposed;
|
||||
private readonly StringBuilder _outputBuffer = new();
|
||||
private readonly StringBuilder _errorBuffer = new();
|
||||
private readonly ManualResetEventSlim _outputAvailable = new(false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the PowerShell session is running.
|
||||
/// </summary>
|
||||
/// <value><c>true</c> if the session is running; otherwise, <c>false</c>.</value>
|
||||
public bool IsRunning => _process != null && !_process.HasExited;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the PowerShell session is started.
|
||||
/// </summary>
|
||||
/// <exception cref="ObjectDisposedException">Thrown if the session has been disposed.</exception>
|
||||
public void EnsureStarted()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(PowerShellSession));
|
||||
}
|
||||
|
||||
if (_process != null && !_process.HasExited)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StartProcess();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the PowerShell process with elevated privileges.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the PowerShell process cannot be started.</exception>
|
||||
private void StartProcess()
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
Arguments = "-NoProfile -ExecutionPolicy Bypass -Command \"& { while ($true) { $cmd = Read-Host; if ($cmd -eq 'EXIT_SESSION') { break }; try { $output = Invoke-Expression $cmd 2>&1; $output | Out-String -Stream; Write-Output \"___ENDOFCOMMAND___\" } catch { $_.Exception.Message; Write-Output \"___ENDOFCOMMAND___\" } } }\"",
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
Verb = "runas" // Request elevation
|
||||
};
|
||||
|
||||
_process = Process.Start(startInfo);
|
||||
if (_process == null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to start PowerShell process");
|
||||
}
|
||||
|
||||
// Clear any startup output
|
||||
_outputBuffer.Clear();
|
||||
_errorBuffer.Clear();
|
||||
_outputAvailable.Reset();
|
||||
|
||||
// Attach event handlers for async reading
|
||||
_process.OutputDataReceived += OnOutputDataReceived;
|
||||
_process.ErrorDataReceived += OnErrorDataReceived;
|
||||
|
||||
_process.BeginOutputReadLine();
|
||||
_process.BeginErrorReadLine();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the output data received from the PowerShell process.
|
||||
/// </summary>
|
||||
/// <param name="sender">The source of the event.</param>
|
||||
/// <param name="e">The event data.</param>
|
||||
private void OnOutputDataReceived(object sender, DataReceivedEventArgs e)
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
lock (_outputBuffer)
|
||||
{
|
||||
_outputBuffer.AppendLine(e.Data);
|
||||
if (e.Data.Contains("___ENDOFCOMMAND___"))
|
||||
{
|
||||
_outputAvailable.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the error data received from the PowerShell process.
|
||||
/// </summary>
|
||||
/// <param name="sender">The source of the event.</param>
|
||||
/// <param name="e">The event data.</param>
|
||||
private void OnErrorDataReceived(object sender, DataReceivedEventArgs e)
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
lock (_errorBuffer)
|
||||
{
|
||||
_errorBuffer.AppendLine(e.Data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a PowerShell script and returns the output.
|
||||
/// </summary>
|
||||
/// <param name="script">The PowerShell script to execute.</param>
|
||||
/// <param name="timeoutSeconds">The timeout in seconds for the script execution.</param>
|
||||
/// <returns>The output from PowerShell.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the PowerShell process is not running.</exception>
|
||||
/// <exception cref="TimeoutException">Thrown if the command times out.</exception>
|
||||
public string ExecuteScript(string script, int timeoutSeconds)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureStarted();
|
||||
|
||||
if (_process == null || _process.HasExited)
|
||||
{
|
||||
throw new InvalidOperationException("PowerShell process is not running");
|
||||
}
|
||||
|
||||
// Clear previous output and reset the event
|
||||
lock (_outputBuffer)
|
||||
{
|
||||
_outputBuffer.Clear();
|
||||
}
|
||||
lock (_errorBuffer)
|
||||
{
|
||||
_errorBuffer.Clear();
|
||||
}
|
||||
_outputAvailable.Reset();
|
||||
|
||||
// Write the script to stdin using base64 encoding to handle special characters
|
||||
var encodedScript = Convert.ToBase64String(Encoding.UTF8.GetBytes(script));
|
||||
var command = $"[System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encodedScript}')) | Invoke-Expression";
|
||||
|
||||
_process.StandardInput.WriteLine(command);
|
||||
|
||||
// Wait for output with a timeout
|
||||
var timeout = TimeSpan.FromSeconds(timeoutSeconds);
|
||||
if (!_outputAvailable.Wait(timeout))
|
||||
{
|
||||
throw new TimeoutException($"PowerShell command timed out after {timeout.TotalSeconds} seconds.\nScript:\n{script}");
|
||||
}
|
||||
|
||||
// Give a bit more time for all output to arrive
|
||||
Thread.Sleep(50);
|
||||
|
||||
lock (_outputBuffer)
|
||||
{
|
||||
var rawOutput = _outputBuffer.ToString();
|
||||
_outputBuffer.Clear();
|
||||
|
||||
// Remove the end marker and any lines that contain our base64 command
|
||||
var lines = rawOutput.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var filteredLines = lines
|
||||
.Where(line => !line.Contains("___ENDOFCOMMAND___"))
|
||||
.Where(line => !line.Contains("FromBase64String"))
|
||||
.Where(line => !line.Contains(encodedScript.Substring(0, Math.Min(20, encodedScript.Length))))
|
||||
.ToList();
|
||||
|
||||
var output = string.Join(Environment.NewLine, filteredLines).Trim();
|
||||
|
||||
lock (_errorBuffer)
|
||||
{
|
||||
var error = _errorBuffer.ToString().Trim();
|
||||
_errorBuffer.Clear();
|
||||
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
throw new InvalidOperationException($"PowerShell error: {error}\nScript:\n{script}");
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the PowerShell session.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (_process != null)
|
||||
{
|
||||
// Unregister event handlers
|
||||
_process.OutputDataReceived -= OnOutputDataReceived;
|
||||
_process.ErrorDataReceived -= OnErrorDataReceived;
|
||||
|
||||
if (!_process.HasExited)
|
||||
{
|
||||
try
|
||||
{
|
||||
_process.StandardInput.WriteLine("EXIT_SESSION");
|
||||
if (!_process.WaitForExit(5000))
|
||||
{
|
||||
_process.Kill();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors during cleanup
|
||||
try { _process.Kill(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
_process.Dispose();
|
||||
_process = null;
|
||||
}
|
||||
|
||||
_outputAvailable.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Windows implementation of <see cref="IDiskImageHelper"/> using PowerShell and VHD files.
|
||||
/// </summary>
|
||||
internal class WindowsDiskImageHelper : IDiskImageHelper
|
||||
{
|
||||
// Static PowerShell session that persists across method calls
|
||||
private static PowerShellSession? _session;
|
||||
private static readonly object _sessionLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the shared PowerShell session, creating it if necessary.
|
||||
/// </summary>
|
||||
/// <returns>The shared <see cref="PowerShellSession"/> instance.</returns>
|
||||
private static PowerShellSession GetSession()
|
||||
{
|
||||
lock (_sessionLock)
|
||||
{
|
||||
if (_session == null)
|
||||
{
|
||||
_session = new PowerShellSession();
|
||||
}
|
||||
return _session;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a PowerShell script using the persistent session.
|
||||
/// </summary>
|
||||
/// <param name="script">The PowerShell script to execute.</param>
|
||||
/// <param name="timeoutSeconds">The timeout in seconds for the script execution.</param>
|
||||
/// <returns>The output from PowerShell.</returns>
|
||||
private static string RunPowerShell(string script, int timeoutSeconds = 30)
|
||||
{
|
||||
var session = GetSession();
|
||||
return session.ExecuteScript(script, timeoutSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the disk number from a disk identifier string (e.g., "\\.\PhysicalDrive2" -> 2).
|
||||
/// </summary>
|
||||
/// <param name="diskIdentifier">The disk identifier string.</param>
|
||||
/// <returns>The disk number.</returns>
|
||||
private static int ParseDiskNumber(string diskIdentifier)
|
||||
{
|
||||
const string prefix = @"\\.\PhysicalDrive";
|
||||
if (diskIdentifier.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& int.TryParse(diskIdentifier.Substring(prefix.Length), out int num))
|
||||
return num;
|
||||
|
||||
throw new ArgumentException($"Invalid disk identifier: {diskIdentifier}. Expected format: \\\\.\\PhysicalDriveN", nameof(diskIdentifier));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the disk number for a VHD image file.
|
||||
/// </summary>
|
||||
/// <param name="imagePath">The path to the VHD file.</param>
|
||||
/// <returns>The disk number, or -1 if not found.</returns>
|
||||
private static int GetDiskNumber(string imagePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var script = $@"
|
||||
$image = Get-DiskImage -ImagePath '{imagePath}'
|
||||
if ($image -and $image.Attached) {{
|
||||
$image | Get-Disk | Select-Object -ExpandProperty Number
|
||||
}}
|
||||
";
|
||||
var result = RunPowerShell(script)?.Trim();
|
||||
|
||||
if (!string.IsNullOrEmpty(result) && int.TryParse(result, out int diskNumber))
|
||||
return diskNumber;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: PowerShell GetDiskNumber failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the drive letters (mount points) for all partitions on a disk.
|
||||
/// </summary>
|
||||
/// <param name="diskNumber">The disk number.</param>
|
||||
/// <returns>An array of mount point paths (e.g., "E:\").</returns>
|
||||
private static string[] GetMountPoints(int diskNumber)
|
||||
{
|
||||
var script = $@"
|
||||
Get-Partition -DiskNumber {diskNumber} |
|
||||
Where-Object {{ $_.DriveLetter -and $_.DriveLetter -ne [char]0 }} |
|
||||
ForEach-Object {{ $_.DriveLetter }}
|
||||
";
|
||||
var result = RunPowerShell(script)?.Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(result))
|
||||
return [];
|
||||
|
||||
return result
|
||||
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(l => l.Trim())
|
||||
.Where(l => l.Length == 1 && char.IsLetter(l[0]))
|
||||
.Select(l => $"{char.ToUpperInvariant(l[0])}:\\")
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CreateDisk(string imagePath, long sizeB)
|
||||
{
|
||||
// Ensure the directory exists
|
||||
var directory = Path.GetDirectoryName(imagePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
// Delete existing VHD if it exists
|
||||
if (File.Exists(imagePath))
|
||||
try
|
||||
{
|
||||
CleanupDisk(imagePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
|
||||
// Create the VHD using PowerShell
|
||||
// Windows GPT initialization adds 16 MB of overhead, which means that the partitions may not have enough disk towards the end.
|
||||
var overheadBytes = 16 * 1024 * 1024;
|
||||
var script = $@"New-VHD -Path '{imagePath}' -SizeBytes {sizeB + overheadBytes} -Fixed | Out-Null; Mount-DiskImage -ImagePath '{imagePath}' | Out-Null";
|
||||
RunPowerShell(script);
|
||||
|
||||
// Wait for the disk to be attached and get the disk number
|
||||
int diskNumber = WaitForDiskAttachment(imagePath, TimeSpan.FromSeconds(5));
|
||||
if (diskNumber < 0)
|
||||
throw new InvalidOperationException($"Failed to get disk number for VHD: {imagePath}");
|
||||
|
||||
return $@"\\.\PhysicalDrive{diskNumber}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a VHD to be attached and returns its disk number.
|
||||
/// </summary>
|
||||
/// <param name="imagePath">The path to the VHD file.</param>
|
||||
/// <param name="timeout">Maximum time to wait.</param>
|
||||
/// <returns>The disk number, or -1 if not found within the timeout.</returns>
|
||||
private static int WaitForDiskAttachment(string imagePath, TimeSpan timeout)
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
while (DateTime.UtcNow - startTime < timeout)
|
||||
{
|
||||
var diskNumber = GetDiskNumber(imagePath);
|
||||
if (diskNumber >= 0)
|
||||
return diskNumber;
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string[] InitializeDisk(string diskIdentifier, PartitionTableType tableType, (FileSystemType, long)[] partitions)
|
||||
{
|
||||
if (tableType == PartitionTableType.Unknown)
|
||||
return []; // No partition table, so nothing to initialize
|
||||
|
||||
var diskNumber = ParseDiskNumber(diskIdentifier);
|
||||
|
||||
// Initialize the disk with the specified partition style
|
||||
var script = $@"
|
||||
$disk = Get-Disk -Number {diskNumber}
|
||||
if ($disk.PartitionStyle -ne 'RAW') {{
|
||||
Clear-Disk -Number {diskNumber} -RemoveData -Confirm:$false
|
||||
}}
|
||||
Initialize-Disk -Number {diskNumber} -PartitionStyle {tableType.ToString().ToUpperInvariant()} -PassThru | Out-Null
|
||||
";
|
||||
RunPowerShell(script);
|
||||
|
||||
// Wait for the initialization to complete
|
||||
WaitForDiskInitialization(diskNumber, TimeSpan.FromSeconds(5));
|
||||
|
||||
if (partitions.Length == 0)
|
||||
return [];
|
||||
|
||||
// Create and format each partition
|
||||
foreach (var (fsType, sizeB) in partitions)
|
||||
{
|
||||
var fsTypeStr = fsType.ToString().ToUpperInvariant();
|
||||
var sizeParam = sizeB > 0 ? $"-Size {sizeB}" : "-UseMaximumSize";
|
||||
|
||||
script = $@"
|
||||
$partition = New-Partition -DiskNumber {diskNumber} {sizeParam} -AssignDriveLetter
|
||||
# Wait for the partition to be ready
|
||||
$timeout = (Get-Date).AddSeconds(30)
|
||||
while ((Get-Date) -lt $timeout) {{
|
||||
$vol = Get-Partition -DiskNumber {diskNumber} | Where-Object {{ $_.PartitionNumber -eq $partition.PartitionNumber }} | Get-Volume
|
||||
if ($vol) {{ break }}
|
||||
Start-Sleep -Milliseconds 100
|
||||
}}
|
||||
# Format the volume
|
||||
Format-Volume -Partition $partition -FileSystem {fsTypeStr} -NewFileSystemLabel 'TestVol' -Confirm:$false | Out-Null
|
||||
";
|
||||
RunPowerShell(script);
|
||||
}
|
||||
|
||||
return GetMountPoints(diskNumber);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a disk to be initialized.
|
||||
/// </summary>
|
||||
/// <param name="diskNumber">The disk number.</param>
|
||||
/// <param name="timeout">Maximum time to wait.</param>
|
||||
/// <exception cref="TimeoutException">Thrown if initialization times out.</exception>
|
||||
private static void WaitForDiskInitialization(int diskNumber, TimeSpan timeout)
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
while (DateTime.UtcNow - startTime < timeout)
|
||||
{
|
||||
var script = $"(Get-Disk -Number {diskNumber}).PartitionStyle";
|
||||
var result = RunPowerShell(script)?.Trim();
|
||||
if (result == "GPT" || result == "MBR")
|
||||
return;
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
throw new TimeoutException($"Disk {diskNumber} initialization timed out");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string[] Mount(string diskIdentifier, string? baseMountPath = null, bool readOnly = false, FileSystemType[]? fileSystemTypes = null)
|
||||
{
|
||||
var diskNumber = ParseDiskNumber(diskIdentifier);
|
||||
|
||||
// Check if already mounted
|
||||
try
|
||||
{
|
||||
var mountPoints = GetMountPoints(diskNumber);
|
||||
if (mountPoints.Length > 0)
|
||||
return mountPoints;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors and try mounting
|
||||
}
|
||||
|
||||
// Bring the disk online and assign drive letters
|
||||
var script = $@"
|
||||
Set-Disk -Number {diskNumber} -IsOffline $false
|
||||
Update-Disk -Number {diskNumber}
|
||||
Update-HostStorageCache
|
||||
Get-Partition -DiskNumber {diskNumber} | Where-Object {{ $_.Type -ne 'Reserved' -and $_.Type -ne 'System' -and -not $_.DriveLetter }} | ForEach-Object {{
|
||||
$_ | Add-PartitionAccessPath -AssignDriveLetter
|
||||
}}
|
||||
";
|
||||
RunPowerShell(script);
|
||||
|
||||
return WaitForMountPoints(diskNumber);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for mount points to be fully established after a mount operation.
|
||||
/// Uses exponential backoff starting from a short delay to minimize wait time
|
||||
/// in the common case while still handling slow mounts.
|
||||
/// </summary>
|
||||
/// <param name="diskNumber">The disk number to check mount points for.</param>
|
||||
/// <returns>An array of mount point paths.</returns>
|
||||
private static string[] WaitForMountPoints(int diskNumber)
|
||||
{
|
||||
const int maxRetries = 2;
|
||||
|
||||
for (int i = 0; i < maxRetries; i++)
|
||||
{
|
||||
var mountPoints = GetMountPoints(diskNumber);
|
||||
|
||||
if (mountPoints.Length > 0)
|
||||
{
|
||||
// Check if all mount points have filesystem entries (not empty)
|
||||
var allNonEmpty = mountPoints.All(mp =>
|
||||
{
|
||||
try { return Directory.GetFileSystemEntries(mp).Length > 0; }
|
||||
catch { return false; }
|
||||
});
|
||||
|
||||
if (allNonEmpty || i == maxRetries - 1)
|
||||
return mountPoints;
|
||||
}
|
||||
}
|
||||
|
||||
// Final attempt - return whatever we have
|
||||
return GetMountPoints(diskNumber);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Unmount(string diskIdentifier)
|
||||
{
|
||||
var diskNumber = ParseDiskNumber(diskIdentifier);
|
||||
|
||||
// Remove drive letters from all partitions
|
||||
var script = $@"
|
||||
Get-Partition -DiskNumber {diskNumber} |
|
||||
Where-Object {{ $_.DriveLetter -and $_.DriveLetter -ne [char]0 }} |
|
||||
ForEach-Object {{
|
||||
$letter = $_.DriveLetter
|
||||
Remove-PartitionAccessPath -DiskNumber {diskNumber} -PartitionNumber $_.PartitionNumber -AccessPath ""$($letter):\""
|
||||
}}
|
||||
";
|
||||
RunPowerShell(script);
|
||||
|
||||
// Pull the disk offline to ensure it's not in use
|
||||
script = $@"
|
||||
Set-Disk -Number {diskNumber} -IsOffline $true
|
||||
Set-Disk -Number {diskNumber} -IsReadOnly $false
|
||||
";
|
||||
RunPowerShell(script);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void FlushDisk(string diskIdentifier)
|
||||
{
|
||||
var diskNumber = ParseDiskNumber(diskIdentifier);
|
||||
try
|
||||
{
|
||||
// Flush all volumes on the disk
|
||||
var script = $@"
|
||||
Get-Partition -DiskNumber {diskNumber} |
|
||||
Where-Object {{ $_.DriveLetter -and $_.DriveLetter -ne [char]0 }} |
|
||||
ForEach-Object {{
|
||||
Write-VolumeCache -DriveLetter $_.DriveLetter
|
||||
}}
|
||||
";
|
||||
RunPowerShell(script);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to flush volume cache: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CleanupDisk(string imagePath, string? diskIdentifier = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var script = $@"
|
||||
$image = Get-DiskImage -ImagePath '{imagePath}'
|
||||
if ($image -and $image.Attached) {{
|
||||
Dismount-DiskImage -ImagePath '{imagePath}'
|
||||
}}
|
||||
";
|
||||
RunPowerShell(script);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to detach disk image: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(imagePath))
|
||||
File.Delete(imagePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to delete disk image file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasRequiredPrivileges()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var principal = new WindowsPrincipal(identity);
|
||||
return principal.IsInRole(WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PartitionTableGeometry GetPartitionTable(string diskIdentifier)
|
||||
{
|
||||
var diskNumber = ParseDiskNumber(diskIdentifier);
|
||||
|
||||
var script = $@"
|
||||
$disk = Get-Disk -Number {diskNumber}
|
||||
Write-Output ""PartitionStyle:$($disk.PartitionStyle)""
|
||||
Write-Output ""Size:$($disk.Size)""
|
||||
Write-Output ""LogicalSectorSize:$($disk.LogicalSectorSize)""
|
||||
";
|
||||
var output = RunPowerShell(script);
|
||||
|
||||
PartitionTableType tableType = PartitionTableType.Unknown;
|
||||
long size = -1;
|
||||
int sectorSize = -1;
|
||||
|
||||
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var parts = line.Split([':'], 2);
|
||||
if (parts.Length != 2) continue;
|
||||
|
||||
switch (parts[0].Trim())
|
||||
{
|
||||
case "PartitionStyle":
|
||||
tableType = parts[1].Trim() switch
|
||||
{
|
||||
"GPT" => PartitionTableType.GPT,
|
||||
"MBR" => PartitionTableType.MBR,
|
||||
_ => PartitionTableType.Unknown
|
||||
};
|
||||
break;
|
||||
case "Size":
|
||||
if (long.TryParse(parts[1].Trim(), out long parsedSize))
|
||||
size = parsedSize;
|
||||
break;
|
||||
case "LogicalSectorSize":
|
||||
if (int.TryParse(parts[1].Trim(), out int parsedSectorSize))
|
||||
sectorSize = parsedSectorSize;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (size == -1 || sectorSize == -1)
|
||||
throw new InvalidOperationException($"Failed to retrieve partition table information for disk {diskIdentifier}");
|
||||
|
||||
return new PartitionTableGeometry
|
||||
{
|
||||
Type = tableType,
|
||||
Size = size,
|
||||
SectorSize = sectorSize
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PartitionGeometry[] GetPartitions(string diskIdentifier)
|
||||
{
|
||||
var diskNumber = ParseDiskNumber(diskIdentifier);
|
||||
|
||||
var script = $@"
|
||||
Get-Partition -DiskNumber {diskNumber} |
|
||||
Where-Object {{ $_.Type -ne 'Reserved' -and $_.Type -ne 'System' }} |
|
||||
ForEach-Object {{
|
||||
$vol = $_ | Get-Volume
|
||||
Write-Output ""---PARTITION---""
|
||||
Write-Output ""Number:$($_.PartitionNumber)""
|
||||
Write-Output ""Type:$($_.Type)""
|
||||
Write-Output ""Offset:$($_.Offset)""
|
||||
Write-Output ""Size:$($_.Size)""
|
||||
Write-Output ""GptType:$($_.GptType)""
|
||||
Write-Output ""Guid:$($_.Guid)""
|
||||
if ($vol) {{
|
||||
Write-Output ""FileSystem:$($vol.FileSystem)""
|
||||
Write-Output ""FileSystemLabel:$($vol.FileSystemLabel)""
|
||||
}}
|
||||
}}
|
||||
";
|
||||
var output = RunPowerShell(script);
|
||||
|
||||
var partitions = new List<PartitionGeometry>();
|
||||
PartitionGeometry? current = null;
|
||||
|
||||
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (line.Trim() == "---PARTITION---")
|
||||
{
|
||||
if (current != null)
|
||||
partitions.Add(current);
|
||||
current = new PartitionGeometry
|
||||
{
|
||||
Number = -1,
|
||||
Type = PartitionType.Unknown,
|
||||
StartOffset = -1,
|
||||
Size = -1,
|
||||
Name = null,
|
||||
FilesystemType = FileSystemType.Unknown,
|
||||
VolumeGuid = null,
|
||||
TableType = PartitionTableType.Unknown
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current == null) continue;
|
||||
|
||||
var parts = line.Split([':'], 2);
|
||||
if (parts.Length != 2) continue;
|
||||
|
||||
switch (parts[0].Trim())
|
||||
{
|
||||
case "Number":
|
||||
if (int.TryParse(parts[1].Trim(), out int num))
|
||||
current.Number = num;
|
||||
break;
|
||||
case "Type":
|
||||
current.Type = parts[1].Trim() switch
|
||||
{
|
||||
"Basic" => PartitionType.Primary,
|
||||
"IFS" => PartitionType.Primary,
|
||||
_ => PartitionType.Unknown
|
||||
};
|
||||
break;
|
||||
case "Offset":
|
||||
if (long.TryParse(parts[1].Trim(), out long offset))
|
||||
current.StartOffset = offset;
|
||||
break;
|
||||
case "Size":
|
||||
if (long.TryParse(parts[1].Trim(), out long size))
|
||||
current.Size = size;
|
||||
break;
|
||||
case "Guid":
|
||||
if (Guid.TryParse(parts[1].Trim(), out Guid guid))
|
||||
current.VolumeGuid = guid;
|
||||
break;
|
||||
case "FileSystem":
|
||||
current.FilesystemType = parts[1].Trim() switch
|
||||
{
|
||||
"NTFS" => FileSystemType.NTFS,
|
||||
"FAT32" => FileSystemType.FAT32,
|
||||
"exFAT" => FileSystemType.ExFAT,
|
||||
"FAT" => FileSystemType.FAT32,
|
||||
"ReFS" => FileSystemType.ReFS,
|
||||
_ => FileSystemType.Unknown
|
||||
};
|
||||
break;
|
||||
case "FileSystemLabel":
|
||||
current.Name = parts[1].Trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (current != null)
|
||||
partitions.Add(current);
|
||||
|
||||
return partitions.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ReAttach(string imagePath, string diskIdentifier, PartitionTableType tableType, bool readOnly = false)
|
||||
{
|
||||
// Unmount the disk first to ensure it's in a clean state
|
||||
Unmount(diskIdentifier);
|
||||
|
||||
// Flush any pending writes and pull the disk offline
|
||||
FlushDisk(diskIdentifier);
|
||||
|
||||
// Re-attach the disk image with the desired read-only state
|
||||
var script = $@"
|
||||
$image = Get-DiskImage -ImagePath '{imagePath}'
|
||||
if ($image) {{
|
||||
$disk = $image | Get-Disk
|
||||
# Pull disk online to repopulate Windows cache
|
||||
if ($disk.IsOffline -Eq $true) {{
|
||||
$disk | Set-Disk -IsOffline $false
|
||||
}}
|
||||
if ($image.Attached) {{
|
||||
Dismount-DiskImage -ImagePath '{imagePath}'
|
||||
}}
|
||||
Mount-VHD -Path '{imagePath}' {(readOnly ? "-ReadOnly" : "")}
|
||||
}} else {{
|
||||
throw 'Disk image not found: {imagePath}'
|
||||
}}
|
||||
";
|
||||
var output = RunPowerShell(script, 300);
|
||||
|
||||
// Wait for the disk to be attached and get the new disk number
|
||||
int newDiskNumber = WaitForDiskAttachment(imagePath, TimeSpan.FromSeconds(5));
|
||||
if (newDiskNumber < 0)
|
||||
throw new InvalidOperationException($"Failed to re-attach VHD: {imagePath}");
|
||||
|
||||
return $@"\\.\PhysicalDrive{newDiskNumber}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_sessionLock)
|
||||
{
|
||||
_session?.Dispose();
|
||||
_session = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -698,8 +698,8 @@ namespace Duplicati.UnitTest
|
||||
{
|
||||
var original_dir_info = new DirectoryInfo(os_special_dir);
|
||||
var restored_dir_info = new DirectoryInfo(os_special_dir.Replace(DATAFOLDER, RESTOREFOLDER));
|
||||
var original_dir_rules = original_dir_info.GetAccessControl().GetAccessRules(true, true, typeof(System.Security.Principal.NTAccount));
|
||||
var restored_dir_rules = restored_dir_info.GetAccessControl().GetAccessRules(true, true, typeof(System.Security.Principal.NTAccount));
|
||||
var original_dir_rules = original_dir_info.GetAccessControl().GetAccessRules(true, false, typeof(System.Security.Principal.NTAccount));
|
||||
var restored_dir_rules = restored_dir_info.GetAccessControl().GetAccessRules(true, false, typeof(System.Security.Principal.NTAccount));
|
||||
|
||||
if (skip_metadata && original_dir_info.Attributes != default_dir_attrs)
|
||||
Assert.That(original_dir_info.Attributes, Is.Not.EqualTo(restored_dir_info.Attributes), "Directory attributes should not be equal");
|
||||
@@ -714,8 +714,8 @@ namespace Duplicati.UnitTest
|
||||
|
||||
var original_file_info = new FileInfo(os_special_file);
|
||||
var restored_file_info = new FileInfo(os_special_file.Replace(DATAFOLDER, RESTOREFOLDER));
|
||||
var original_file_rules = original_file_info.GetAccessControl().GetAccessRules(true, true, typeof(System.Security.Principal.NTAccount));
|
||||
var restored_file_rules = restored_file_info.GetAccessControl().GetAccessRules(true, true, typeof(System.Security.Principal.NTAccount));
|
||||
var original_file_rules = original_file_info.GetAccessControl().GetAccessRules(true, false, typeof(System.Security.Principal.NTAccount));
|
||||
var restored_file_rules = restored_file_info.GetAccessControl().GetAccessRules(true, false, typeof(System.Security.Principal.NTAccount));
|
||||
|
||||
if (skip_metadata && original_file_info.Attributes != default_file_attrs)
|
||||
Assert.That(original_file_info.Attributes, Is.Not.EqualTo(restored_file_info.Attributes), "File attributes should not be equal");
|
||||
@@ -969,5 +969,23 @@ namespace Duplicati.UnitTest
|
||||
"Blocklist download pass was required");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Issue6817DollarSignNumberInFilenameBreaksRegex()
|
||||
{
|
||||
var filename = "~$1234567891011121314151617181920.txt";
|
||||
var content = RandomNumberGenerator.GetBytes(1024);
|
||||
TestUtils.WriteFile(Path.Combine(DATAFOLDER, filename), content);
|
||||
|
||||
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, TestOptions, null))
|
||||
TestUtils.AssertResults(c.Backup([DATAFOLDER]));
|
||||
|
||||
// Delete the database
|
||||
File.Delete(DBFILE);
|
||||
|
||||
// Recreate the database, which would fail if the filename is not handled correctly
|
||||
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, TestOptions, null))
|
||||
TestUtils.AssertResults(c.Repair());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,5 +229,35 @@ namespace Duplicati.UnitTest
|
||||
|
||||
Assert.That(options.IndexfilePolicy, Is.EqualTo(Options.IndexFileStrategy.Lookup));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Options")]
|
||||
public void RestoreVolumeCacheHintDefaultsToUnlimited()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
|
||||
Assert.That(options.RestoreVolumeCacheHint, Is.EqualTo(-1L));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Options")]
|
||||
public void RestoreVolumeCacheMinFreeDefaultsToOneGigabyte()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
|
||||
Assert.That(options.RestoreVolumeCacheMinFree, Is.EqualTo(1L * 1024 * 1024 * 1024));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Options")]
|
||||
public void RestoreVolumeCacheMinFreeBareNumberTreatedAsGigabytes()
|
||||
{
|
||||
var options = CreateOptions(new Dictionary<string, string?>
|
||||
{
|
||||
{ "restore-volume-cache-min-free", "2" }
|
||||
});
|
||||
|
||||
Assert.That(options.RestoreVolumeCacheMinFree, Is.EqualTo(2L * 1024 * 1024 * 1024));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+294
-264
File diff suppressed because it is too large
Load Diff
@@ -166,12 +166,13 @@ namespace Duplicati.UnitTest
|
||||
|
||||
[Test]
|
||||
[Category("RestoreHandler")]
|
||||
public async System.Threading.Tasks.Task RestoreVolumeCache([Values("0b", "1mb", "5mb", "1gb")] string cache_size, [Values("0", "1", null)] string? channel_size)
|
||||
public async System.Threading.Tasks.Task RestoreVolumeCache([Values("0b", "1mb", "5mb", "1gb", null)] string? cache_size, [Values("0", "1", null)] string? channel_size)
|
||||
{
|
||||
var opts = TestOptions;
|
||||
opts["dblock-size"] = "1mb";
|
||||
opts["blocksize"] = "1kb";
|
||||
opts["restore-volume-cache-hint"] = cache_size;
|
||||
if (cache_size != null)
|
||||
opts["restore-volume-cache-hint"] = cache_size;
|
||||
if (channel_size != null)
|
||||
opts["restore-channel-buffer-size"] = channel_size;
|
||||
opts["restore-legacy"] = "false";
|
||||
@@ -217,6 +218,38 @@ namespace Duplicati.UnitTest
|
||||
TestUtils.AssertDirectoryTreesAreEquivalent(this.DATAFOLDER, this.RESTOREFOLDER, true, "Restoring with different volume cache sizes");
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RestoreHandler")]
|
||||
public void RestoreInternalProfilingLogsCacheUsage()
|
||||
{
|
||||
var sourceFilePath = Path.Combine(this.DATAFOLDER, "profiled-restore.bin");
|
||||
File.WriteAllBytes(sourceFilePath, new byte[256 * 1024]);
|
||||
|
||||
var backupOptions = new Dictionary<string, string>(this.TestOptions)
|
||||
{
|
||||
["dblock-size"] = "100kb",
|
||||
["blocksize"] = "10kb"
|
||||
};
|
||||
|
||||
using (var c = new Controller("file://" + this.TARGETFOLDER, backupOptions, null))
|
||||
TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER }));
|
||||
|
||||
var restoreOptions = new Dictionary<string, string>(this.TestOptions)
|
||||
{
|
||||
["restore-path"] = this.RESTOREFOLDER,
|
||||
["restore-legacy"] = "false",
|
||||
["restore-with-local-blocks"] = "false",
|
||||
["restore-volume-cache-hint"] = "1mb",
|
||||
["internal-profiling"] = "true"
|
||||
};
|
||||
|
||||
using (var c = new Controller("file://" + this.TARGETFOLDER, restoreOptions, null))
|
||||
TestUtils.AssertResults(c.Restore(new[] { "*" }));
|
||||
|
||||
var logContents = File.ReadAllText(this.LOGFILE);
|
||||
Assert.That(logContents, Does.Contain("Max used cache size:"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RestoreHandler")]
|
||||
public void RestoreWithoutLocalData([Values("true", "false")] string noLocalDb, [Values("true", "false")] string patchWithLocalBlocks)
|
||||
@@ -349,5 +382,61 @@ namespace Duplicati.UnitTest
|
||||
TestUtils.AssertResults(res_restore);
|
||||
Assert.AreEqual(original_contents, File.ReadAllBytes(file1Path));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RestoreHandler")]
|
||||
public async System.Threading.Tasks.Task RestoreVolumeCacheDiskPressure()
|
||||
{
|
||||
var opts = TestOptions;
|
||||
opts["dblock-size"] = "1mb";
|
||||
opts["blocksize"] = "1kb";
|
||||
// No restore-volume-cache-hint → unlimited mode (-1 sentinel).
|
||||
// Set restore-volume-cache-min-free to an absurdly large value so
|
||||
// every volume arrival triggers disk-pressure eviction.
|
||||
opts["restore-volume-cache-min-free"] = "999tb";
|
||||
opts["restore-legacy"] = "false";
|
||||
opts["restore-path"] = RESTOREFOLDER;
|
||||
|
||||
// Write enough data to create at least 10 dblock volumes (dblock-size=1mb),
|
||||
// so that the eviction loop is entered multiple times and the CachePressure
|
||||
// warning threshold (5 evictions) is reliably crossed.
|
||||
Random rng = new();
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var data = new byte[512 * 1024]; // 512 KB each → ~10 MB total → ~10 dblock volumes
|
||||
rng.NextBytes(data);
|
||||
File.WriteAllBytes(Path.Combine(this.DATAFOLDER, $"file{i}"), data);
|
||||
}
|
||||
|
||||
using var c = new Controller("file://" + this.TARGETFOLDER, opts, null);
|
||||
TestUtils.AssertResults(c.Backup([this.DATAFOLDER]));
|
||||
|
||||
var timeout_task = System.Threading.Tasks.Task.Delay(TimeSpan.FromSeconds(120));
|
||||
RestoreResults? result = null;
|
||||
|
||||
var restore_task = System.Threading.Tasks.Task.Run(() =>
|
||||
{
|
||||
result = (RestoreResults)c.Restore(["*"]);
|
||||
});
|
||||
|
||||
var t = await System.Threading.Tasks.Task.WhenAny(timeout_task, restore_task);
|
||||
if (t == timeout_task)
|
||||
{
|
||||
c.Abort();
|
||||
await restore_task;
|
||||
Assert.Fail("Restore timed out");
|
||||
}
|
||||
else
|
||||
{
|
||||
t.GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.That(result!.CachePressureEvictions, Is.GreaterThan(0), "Expected disk-pressure evictions with 999tb min-free");
|
||||
Assert.That(result.TotalVolumesAccessed, Is.GreaterThan(0), "Expected at least one volume to be accessed");
|
||||
Assert.That(result.Warnings.Count(), Is.GreaterThanOrEqualTo(1), "Expected at least one CachePressure warning");
|
||||
|
||||
TestUtils.AssertDirectoryTreesAreEquivalent(this.DATAFOLDER, this.RESTOREFOLDER, true, "Restoring with disk pressure eviction");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Library.Main.Operation.RemoteSynchronization;
|
||||
|
||||
namespace RemoteSynchronization
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Duplicati\Library\RemoteSynchronization\Duplicati.Library.RemoteSynchronization.csproj" />
|
||||
<ProjectReference Include="..\..\Duplicati\Library\Main\Duplicati.Library.Main.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Generated
+3
-3
@@ -1550,9 +1550,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const NGAX_URL = process.env.NGAX_URL || "http://localhost:8200/ngax/index.html";
|
||||
|
||||
test("ngax redirects to login on terminal refresh failures", async ({ page }) => {
|
||||
let refreshCalls = 0;
|
||||
|
||||
await page.route("**/api/v1/auth/refresh", async (route) => {
|
||||
refreshCalls++;
|
||||
await route.fulfill({
|
||||
status: 415,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ Error: "Unsupported Media Type" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(NGAX_URL, { waitUntil: "domcontentloaded" });
|
||||
|
||||
// The app should immediately abandon refresh retries and go to login.
|
||||
await page.waitForURL(/\/login\.html(\?.*)?$/, { timeout: 15000 });
|
||||
|
||||
// Give it a short window and ensure no runaway retry loop occurs.
|
||||
await page.waitForTimeout(1500);
|
||||
expect(refreshCalls).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
test("ngax keeps retry behavior for transient refresh failures", async ({ page }) => {
|
||||
let refreshCalls = 0;
|
||||
|
||||
await page.route("**/api/v1/auth/refresh", async (route) => {
|
||||
refreshCalls++;
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ Error: "Service Unavailable" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(NGAX_URL, { waitUntil: "domcontentloaded" });
|
||||
|
||||
// For transient failures, the app should not force a login redirect.
|
||||
await page.waitForTimeout(2500);
|
||||
await expect(page).not.toHaveURL(/\/login\.html(\?.*)?$/);
|
||||
|
||||
// The reconnect flow should attempt refresh again rather than terminating.
|
||||
expect(refreshCalls).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
@@ -0,0 +1,935 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Proprietary.DiskImage.Disk;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
using Duplicati.Proprietary.DiskImage.Partition;
|
||||
|
||||
namespace Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for FAT32 filesystems, storing cluster and block size information.
|
||||
/// </summary>
|
||||
public sealed record Fat32FilesystemMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the block size used for reading/writing data.
|
||||
/// </summary>
|
||||
public int BlockSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the size of a cluster in bytes.
|
||||
/// </summary>
|
||||
public int ClusterSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of clusters in the volume.
|
||||
/// </summary>
|
||||
public long TotalClusters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of allocated (in-use) clusters.
|
||||
/// </summary>
|
||||
public long AllocatedClusters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of free clusters.
|
||||
/// </summary>
|
||||
public long FreeClusters { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a file (block) in a FAT32 filesystem.
|
||||
/// </summary>
|
||||
public class Fat32File : IFile
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string? Path { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public long? Address { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public long Size { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsDirectory => false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last modification timestamp of the block.
|
||||
/// This is the maximum timestamp of any file whose clusters overlap this block.
|
||||
/// </summary>
|
||||
public DateTime? LastModified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the block contains any allocated clusters.
|
||||
/// </summary>
|
||||
public bool IsAllocated { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a FAT32 filesystem.
|
||||
/// Provides filesystem-aware block-level access with cluster allocation tracking.
|
||||
/// </summary>
|
||||
internal class Fat32Filesystem : IFilesystem
|
||||
{
|
||||
/// <summary>
|
||||
/// Cache of zero buffers keyed by block size.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<int, byte[]> s_zeroBuffers = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public FileSystemType Type => FileSystemType.FAT32;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IPartition Partition { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the block size used for reading/writing data.
|
||||
/// </summary>
|
||||
private readonly int m_blockSize;
|
||||
|
||||
/// <summary>
|
||||
/// The parsed FAT32 boot sector.
|
||||
/// </summary>
|
||||
private readonly Fat32BootSector m_bootSector;
|
||||
|
||||
/// <summary>
|
||||
/// The FAT table reader.
|
||||
/// </summary>
|
||||
private readonly Fat32Table m_fatTable;
|
||||
|
||||
/// <summary>
|
||||
/// The directory walker that built the cluster-to-timestamp map.
|
||||
/// </summary>
|
||||
private readonly Fat32DirectoryWalker m_directoryWalker;
|
||||
|
||||
/// <summary>
|
||||
/// Maps block index to block metadata (LastModified, IsAllocated).
|
||||
/// </summary>
|
||||
private readonly BlockMetadata[] m_blockMetadata;
|
||||
|
||||
/// <summary>
|
||||
/// The total number of blocks in the partition.
|
||||
/// </summary>
|
||||
private readonly long m_blockCount;
|
||||
|
||||
/// <summary>
|
||||
/// Flag to indicate whether the object has been disposed.
|
||||
/// </summary>
|
||||
private bool m_disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for a single block.
|
||||
/// </summary>
|
||||
private readonly struct BlockMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// The last modification timestamp for this block.
|
||||
/// </summary>
|
||||
public DateTime LastModified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this block contains any allocated clusters.
|
||||
/// </summary>
|
||||
public bool IsAllocated { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Fat32Filesystem"/> class.
|
||||
/// </summary>
|
||||
/// <param name="partition">The parent partition.</param>
|
||||
/// <param name="blockSize">The block size for reading/writing (default is 1MB).</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown if partition is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown if block size is invalid.</exception>
|
||||
internal Fat32Filesystem(IPartition partition, int blockSize = 1024 * 1024)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(partition);
|
||||
|
||||
Partition = partition;
|
||||
|
||||
var sectorSize = partition.PartitionTable.RawDisk?.SectorSize ?? 512;
|
||||
if (blockSize <= 0 || blockSize % sectorSize != 0)
|
||||
throw new ArgumentException($"Block size must be positive and a multiple of the sector size ({sectorSize} bytes).", nameof(blockSize));
|
||||
|
||||
m_blockSize = blockSize;
|
||||
|
||||
// Read and parse the boot sector
|
||||
var bootSectorData = new byte[512];
|
||||
using (var stream = partition.OpenReadAsync(CancellationToken.None).Result)
|
||||
{
|
||||
stream.ReadExactly(bootSectorData);
|
||||
}
|
||||
m_bootSector = new Fat32BootSector(bootSectorData);
|
||||
|
||||
// Validate block size is a multiple of cluster size
|
||||
if (m_blockSize % m_bootSector.ClusterSize != 0)
|
||||
throw new ArgumentException($"Block size ({m_blockSize}) must be a multiple of the cluster size ({m_bootSector.ClusterSize}).", nameof(blockSize));
|
||||
|
||||
// Read the FAT table
|
||||
m_fatTable = new Fat32Table(partition, m_bootSector, CancellationToken.None);
|
||||
|
||||
// Walk the directory tree to build cluster-to-timestamp map
|
||||
m_directoryWalker = new Fat32DirectoryWalker(partition, m_bootSector, m_fatTable, CancellationToken.None);
|
||||
|
||||
// Pre-compute block metadata
|
||||
m_blockCount = (partition.Size + m_blockSize - 1) / m_blockSize;
|
||||
m_blockMetadata = BuildBlockMetadata();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the metadata array for all blocks.
|
||||
/// </summary>
|
||||
/// <returns>An array of BlockMetadata for each block.</returns>
|
||||
private BlockMetadata[] BuildBlockMetadata()
|
||||
{
|
||||
var metadata = new BlockMetadata[m_blockCount];
|
||||
var clusterToTimestampMap = m_directoryWalker.ClusterToTimestampMap;
|
||||
var dataRegionStart = m_bootSector.DataStartOffset;
|
||||
var dataRegionEnd = dataRegionStart + (long)m_bootSector.TotalDataClusters * m_bootSector.ClusterSize;
|
||||
var fatRegionEnd = dataRegionStart;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (long blockIndex = 0; blockIndex < m_blockCount; blockIndex++)
|
||||
{
|
||||
long blockStart = blockIndex * m_blockSize;
|
||||
long blockEnd = Math.Min(blockStart + m_blockSize, Partition.Size);
|
||||
|
||||
// Check if block is in reserved/FAT region (before data region)
|
||||
if (blockStart < fatRegionEnd)
|
||||
{
|
||||
// Reserved sectors and FAT region are always allocated
|
||||
metadata[blockIndex] = new BlockMetadata
|
||||
{
|
||||
LastModified = now,
|
||||
IsAllocated = true
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if block is beyond the data region
|
||||
if (blockStart >= dataRegionEnd)
|
||||
{
|
||||
// Beyond the data region - treat as unallocated with epoch timestamp
|
||||
metadata[blockIndex] = new BlockMetadata
|
||||
{
|
||||
LastModified = DateTime.UnixEpoch,
|
||||
IsAllocated = false
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Block is in data region - check which clusters fall within it
|
||||
DateTime maxTimestamp = DateTime.UnixEpoch;
|
||||
bool hasAllocatedClusters = false;
|
||||
|
||||
// Calculate which clusters are in this block
|
||||
long firstClusterInBlock = 2 + (blockStart - dataRegionStart) / m_bootSector.ClusterSize;
|
||||
long lastClusterInBlock = 2 + (blockEnd - 1 - dataRegionStart) / m_bootSector.ClusterSize;
|
||||
|
||||
// Clamp to valid cluster range
|
||||
firstClusterInBlock = Math.Max(2, firstClusterInBlock);
|
||||
lastClusterInBlock = Math.Min(lastClusterInBlock, m_fatTable.TotalClusters - 1);
|
||||
|
||||
for (long cluster = firstClusterInBlock; cluster <= lastClusterInBlock; cluster++)
|
||||
{
|
||||
if (m_fatTable.IsClusterAllocated((uint)cluster))
|
||||
{
|
||||
hasAllocatedClusters = true;
|
||||
|
||||
// Get the timestamp for this cluster
|
||||
if (clusterToTimestampMap.TryGetValue((uint)cluster, out var timestamp))
|
||||
{
|
||||
if (timestamp > maxTimestamp)
|
||||
{
|
||||
maxTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Allocated cluster without a timestamp - use current time
|
||||
maxTimestamp = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metadata[blockIndex] = new BlockMetadata
|
||||
{
|
||||
LastModified = maxTimestamp,
|
||||
IsAllocated = hasAllocatedClusters
|
||||
};
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to detect whether a given partition has a FAT32 file system.
|
||||
/// </summary>
|
||||
/// <param name="disk">The raw disk to check for the filesystem.</param>
|
||||
/// <param name="offset">The offset on the disk where the partition starts.</param>
|
||||
/// <param name="size">The size of the partition.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to true if a valid FAT32 filesystem is detected, otherwise false.</returns>
|
||||
public static async Task<bool> DetectAsync(IRawDisk disk, long offset, int size, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bootSectorData = new byte[512];
|
||||
using var stream = await disk.ReadBytesAsync(offset, size, cancellationToken);
|
||||
var bytesRead = await stream.ReadAsync(bootSectorData, cancellationToken);
|
||||
if (bytesRead < 512)
|
||||
return false;
|
||||
|
||||
// Try FAT32: the constructor validates the boot sector signature and
|
||||
// the "FAT32" filesystem-type string at offset 0x52.
|
||||
_ = new Fat32BootSector(bootSectorData);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Unable to read the partition
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (m_disposed)
|
||||
return;
|
||||
|
||||
m_disposed = true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<object?> GetFilesystemMetadataAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var metadata = new Fat32FilesystemMetadata
|
||||
{
|
||||
BlockSize = m_blockSize,
|
||||
ClusterSize = m_bootSector.ClusterSize,
|
||||
TotalClusters = m_fatTable.TotalClusters,
|
||||
AllocatedClusters = m_fatTable.AllocatedClusters,
|
||||
FreeClusters = m_fatTable.FreeClusters
|
||||
};
|
||||
|
||||
return Task.FromResult<object?>(metadata);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<IFile> ListFilesAsync([EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
for (long i = 0; i < m_blockCount; i++)
|
||||
{
|
||||
long address = i * m_blockSize;
|
||||
long size = Math.Min(m_blockSize, Partition.Size - i * m_blockSize);
|
||||
var blockMeta = m_blockMetadata[i];
|
||||
|
||||
if (size > 0)
|
||||
{
|
||||
yield return new Fat32File()
|
||||
{
|
||||
Path = $"block_{i:X016}",
|
||||
Address = address,
|
||||
Size = size,
|
||||
LastModified = blockMeta.LastModified,
|
||||
IsAllocated = blockMeta.IsAllocated
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<IFile> ListFilesAsync(IFile directory, [EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(directory);
|
||||
|
||||
if (directory is not Fat32File)
|
||||
throw new ArgumentException("The specified directory does not belong to this filesystem.", nameof(directory));
|
||||
if (!directory.IsDirectory)
|
||||
throw new ArgumentException("The specified file is not a directory.", nameof(directory));
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> OpenReadStreamAsync(IFile file, CancellationToken cancellationToken)
|
||||
{
|
||||
if (file is not Fat32File fat32File)
|
||||
throw new ArgumentException("The specified file does not belong to this filesystem.", nameof(file));
|
||||
|
||||
if (file.IsDirectory)
|
||||
throw new ArgumentException("The specified file is a directory.", nameof(file));
|
||||
|
||||
long address = fat32File.Address ?? throw new ArgumentException("File address is required.", nameof(file));
|
||||
long size = fat32File.Size;
|
||||
|
||||
BoundsCheck(address, size);
|
||||
|
||||
if (fat32File.IsAllocated)
|
||||
{
|
||||
return new Fat32Stream(Partition.PartitionTable.RawDisk!, Partition.StartOffset + address, size, readEnabled: true, writeEnabled: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
return new Fat32ZeroStream((int)size);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> OpenReadStreamAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
long address = ParsePathToAddress(path);
|
||||
long size = Math.Min((long)m_blockSize, Partition.Size - address);
|
||||
|
||||
return await OpenReadStreamAsync(new Fat32File { Address = address, Size = size, IsAllocated = true }, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> OpenWriteStreamAsync(IFile file, CancellationToken cancellationToken)
|
||||
{
|
||||
if (file is not Fat32File fat32File)
|
||||
throw new ArgumentException("The specified file does not belong to this filesystem.", nameof(file));
|
||||
|
||||
if (file.IsDirectory)
|
||||
throw new ArgumentException("The specified file is a directory.", nameof(file));
|
||||
|
||||
long address = fat32File.Address ?? throw new ArgumentException("File address is required.", nameof(file));
|
||||
long requestedSize = fat32File.Size;
|
||||
|
||||
BoundsCheck(address, requestedSize);
|
||||
|
||||
// Calculate actual available size based on target partition
|
||||
// This ensures we fail fast if the target is too small
|
||||
long actualSize = Math.Min(requestedSize, Partition.Size - address);
|
||||
if (actualSize < requestedSize)
|
||||
throw new IOException($"Target partition is too small to write block at address 0x{address:X}. " +
|
||||
$"Requested size: {requestedSize} bytes, Available: {actualSize} bytes. " +
|
||||
$"The target disk may be smaller than the source disk.");
|
||||
|
||||
// For write operations, always use the full stream
|
||||
return new Fat32Stream(Partition.PartitionTable.RawDisk!, Partition.StartOffset + address, actualSize, readEnabled: false, writeEnabled: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> OpenWriteStreamAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
long address = ParsePathToAddress(path);
|
||||
long size = Math.Min((long)m_blockSize, Partition.Size - address);
|
||||
|
||||
return await OpenWriteStreamAsync(new Fat32File { Address = address, Size = size, IsAllocated = true }, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> OpenReadWriteStreamAsync(IFile file, CancellationToken cancellationToken)
|
||||
{
|
||||
if (file is not Fat32File fat32File)
|
||||
throw new ArgumentException("The specified file does not belong to this filesystem.", nameof(file));
|
||||
|
||||
if (file.IsDirectory)
|
||||
throw new ArgumentException("The specified file is a directory.", nameof(file));
|
||||
|
||||
long address = fat32File.Address ?? throw new ArgumentException("File address is required.", nameof(file));
|
||||
long requestedSize = fat32File.Size;
|
||||
|
||||
BoundsCheck(address, requestedSize);
|
||||
|
||||
// Calculate actual available size based on target partition
|
||||
// This ensures we fail fast if the target is too small
|
||||
long actualSize = Math.Min(requestedSize, Partition.Size - address);
|
||||
if (actualSize < requestedSize)
|
||||
throw new IOException($"Target partition is too small to write block at address 0x{address:X}. " +
|
||||
$"Requested size: {requestedSize} bytes, Available: {actualSize} bytes. " +
|
||||
$"The target disk may be smaller than the source disk.");
|
||||
|
||||
return new Fat32Stream(Partition.PartitionTable.RawDisk!, Partition.StartOffset + address, actualSize, readEnabled: true, writeEnabled: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> OpenReadWriteStreamAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
long address = ParsePathToAddress(path);
|
||||
long size = Math.Min((long)m_blockSize, Partition.Size - address);
|
||||
|
||||
return await OpenReadWriteStreamAsync(new Fat32File { Address = address, Size = size, IsAllocated = true }, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> GetFileLengthAsync(IFile file, CancellationToken cancellationToken)
|
||||
{
|
||||
if (file is not Fat32File fat32File)
|
||||
throw new ArgumentException("The specified file does not belong to this filesystem.", nameof(file));
|
||||
|
||||
if (file.IsDirectory)
|
||||
throw new ArgumentException("The specified file is a directory.", nameof(file));
|
||||
|
||||
return Task.FromResult(fat32File.Size);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> GetFileLengthAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
long address = ParsePathToAddress(path);
|
||||
long size = Math.Min((long)m_blockSize, Partition.Size - address);
|
||||
|
||||
return Task.FromResult(size);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a file path to extract the corresponding block address.
|
||||
/// </summary>
|
||||
/// <param name="path">The file path to parse.</param>
|
||||
/// <returns>The block address corresponding to the file path.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown if the path format is invalid.</exception>
|
||||
private static long ParsePathToAddress(string path)
|
||||
{
|
||||
// Path format: root/part_{PartitionTableType}_{PartitionNumber}/fs_{FileSystemType}/{Address}
|
||||
// We need to extract the part after the last slash
|
||||
var parts = path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 4)
|
||||
throw new ArgumentException("Invalid path format.", nameof(path));
|
||||
|
||||
return Convert.ToInt64(parts[^1], 16);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the specified range is valid for the partition.
|
||||
/// </summary>
|
||||
/// <param name="start">The starting address of the range.</param>
|
||||
/// <param name="size">The size of the range.</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown if the start or size is negative or exceeds the partition size.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown if the start or size is not a multiple of the sector size.</exception>
|
||||
public void BoundsCheck(long start, long size)
|
||||
{
|
||||
if (start < 0 || size < 0)
|
||||
throw new ArgumentOutOfRangeException("Start and size must be non-negative.");
|
||||
|
||||
if (start + size > Partition.Size)
|
||||
throw new ArgumentOutOfRangeException("The specified range exceeds the partition size.");
|
||||
|
||||
var sectorSize = Partition.PartitionTable.RawDisk?.SectorSize ?? 512;
|
||||
|
||||
if (start % sectorSize != 0 || size % sectorSize != 0)
|
||||
throw new ArgumentException($"Start and size must be multiples of the sector size ({sectorSize} bytes).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A stream that reads data from disk and buffers it in memory for writing back on dispose.
|
||||
/// Similar to UnknownFilesystemStream but specific to FAT32 allocated blocks.
|
||||
/// </summary>
|
||||
private class Fat32Stream : Stream
|
||||
{
|
||||
/// <summary>
|
||||
/// The raw disk to read from.
|
||||
/// </summary>
|
||||
private readonly IRawDisk _disk;
|
||||
|
||||
/// <summary>
|
||||
/// The starting address on the disk for this stream.
|
||||
/// </summary>
|
||||
private readonly long _address;
|
||||
|
||||
/// <summary>
|
||||
/// The size of the stream in bytes.
|
||||
/// </summary>
|
||||
private readonly long _size;
|
||||
|
||||
/// <summary>
|
||||
/// The buffer used to store data before writing to disk.
|
||||
/// </summary>
|
||||
private readonly byte[] _buffer;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the buffer was rented from the ArrayPool.
|
||||
/// </summary>
|
||||
private readonly bool _bufferRented;
|
||||
|
||||
/// <summary>
|
||||
/// The current position within the buffer.
|
||||
/// </summary>
|
||||
private long _position;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the data in the buffer is valid (has been read from disk).
|
||||
/// </summary>
|
||||
private bool _validData;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the stream has been disposed.
|
||||
/// </summary>
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the stream supports reading.
|
||||
/// </summary>
|
||||
private readonly bool _readEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the stream supports writing.
|
||||
/// </summary>
|
||||
private readonly bool _writeEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the buffer has been modified (dirty).
|
||||
/// </summary>
|
||||
private bool _dirty;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Fat32Stream"/> class.
|
||||
/// </summary>
|
||||
/// <param name="disk">The raw disk to read from/write to.</param>
|
||||
/// <param name="address">The starting address on the disk for this stream.</param>
|
||||
/// <param name="size">The size of the stream in bytes.</param>
|
||||
/// <param name="readEnabled">Whether reading is enabled.</param>
|
||||
/// <param name="writeEnabled">Whether writing is enabled.</param>
|
||||
public Fat32Stream(IRawDisk disk, long address, long size, bool readEnabled, bool writeEnabled)
|
||||
{
|
||||
_disk = disk;
|
||||
_address = address;
|
||||
_size = size;
|
||||
_readEnabled = readEnabled;
|
||||
_writeEnabled = writeEnabled;
|
||||
// Rent buffer from ArrayPool to avoid LOH allocations for large buffers (typically 1 MB)
|
||||
_buffer = ArrayPool<byte>.Shared.Rent((int)size);
|
||||
_bufferRented = true;
|
||||
_position = 0;
|
||||
_validData = false;
|
||||
_disposed = false;
|
||||
_dirty = false;
|
||||
}
|
||||
|
||||
public override bool CanRead => _readEnabled;
|
||||
public override bool CanWrite => _writeEnabled;
|
||||
public override bool CanSeek => true;
|
||||
public override long Length => _size;
|
||||
public override long Position
|
||||
{
|
||||
get => _position;
|
||||
set
|
||||
{
|
||||
if (value < 0 || value > _size)
|
||||
throw new ArgumentOutOfRangeException(nameof(value));
|
||||
_position = value;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
// No-op - data is written on dispose
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
long newPosition = origin switch
|
||||
{
|
||||
SeekOrigin.Begin => offset,
|
||||
SeekOrigin.Current => _position + offset,
|
||||
SeekOrigin.End => _size + offset,
|
||||
_ => throw new ArgumentException("Invalid seek origin", nameof(origin))
|
||||
};
|
||||
|
||||
if (newPosition < 0 || newPosition > _size)
|
||||
throw new ArgumentOutOfRangeException(nameof(offset));
|
||||
|
||||
_position = newPosition;
|
||||
return _position;
|
||||
}
|
||||
|
||||
public override void SetLength(long value)
|
||||
{
|
||||
throw new NotSupportedException("Cannot change the length of this stream.");
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (!_readEnabled)
|
||||
throw new NotSupportedException("Read is not supported on this stream.");
|
||||
|
||||
if (buffer == null)
|
||||
throw new ArgumentNullException(nameof(buffer));
|
||||
if (offset < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(offset));
|
||||
if (count < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(count));
|
||||
if (buffer.Length - offset < count)
|
||||
throw new ArgumentException("Invalid offset and count for buffer length");
|
||||
|
||||
if (!_validData)
|
||||
{
|
||||
// Load the data from disk into the buffer on the first read
|
||||
_disk.ReadBytesAsync(_address, _buffer.AsMemory(0, (int)_size), CancellationToken.None).GetAwaiter().GetResult();
|
||||
_validData = true;
|
||||
}
|
||||
|
||||
int bytesToRead = (int)Math.Min(count, _size - _position);
|
||||
if (bytesToRead <= 0)
|
||||
return 0;
|
||||
|
||||
Buffer.BlockCopy(_buffer, (int)_position, buffer, offset, bytesToRead);
|
||||
_position += bytesToRead;
|
||||
return bytesToRead;
|
||||
}
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (!_writeEnabled)
|
||||
throw new NotSupportedException("Write is not supported on this stream.");
|
||||
|
||||
if (buffer == null)
|
||||
throw new ArgumentNullException(nameof(buffer));
|
||||
if (offset < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(offset));
|
||||
if (count < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(count));
|
||||
if (buffer.Length - offset < count)
|
||||
throw new ArgumentException("Invalid offset and count for buffer length");
|
||||
|
||||
if (_position >= _size)
|
||||
throw new IOException($"Cannot write at position {_position}: stream has ended (size: {_size}). The target partition may be smaller than the source.");
|
||||
|
||||
Buffer.BlockCopy(buffer, offset, _buffer, (int)_position, count);
|
||||
_position += count;
|
||||
_validData = true;
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_readEnabled)
|
||||
throw new NotSupportedException("Read is not supported on this stream.");
|
||||
|
||||
if (buffer == null)
|
||||
throw new ArgumentNullException(nameof(buffer));
|
||||
if (offset < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(offset));
|
||||
if (count < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(count));
|
||||
if (buffer.Length - offset < count)
|
||||
throw new ArgumentException("Invalid offset and count for buffer length");
|
||||
|
||||
if (!_validData)
|
||||
{
|
||||
// Load the data from disk into the buffer on the first read
|
||||
await _disk.ReadBytesAsync(_address, _buffer.AsMemory(0, (int)_size), cancellationToken);
|
||||
_validData = true;
|
||||
}
|
||||
|
||||
int bytesToRead = (int)Math.Min(count, _size - _position);
|
||||
if (bytesToRead <= 0)
|
||||
return 0;
|
||||
|
||||
Buffer.BlockCopy(_buffer, (int)_position, buffer, offset, bytesToRead);
|
||||
_position += bytesToRead;
|
||||
return bytesToRead;
|
||||
}
|
||||
|
||||
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_writeEnabled)
|
||||
throw new NotSupportedException("Write is not supported on this stream.");
|
||||
|
||||
if (buffer == null)
|
||||
throw new ArgumentNullException(nameof(buffer));
|
||||
if (offset < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(offset));
|
||||
if (count < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(count));
|
||||
if (buffer.Length - offset < count)
|
||||
throw new ArgumentException("Invalid offset and count for buffer length");
|
||||
|
||||
if (_position >= _size)
|
||||
throw new IOException($"Cannot write at position {_position}: stream has ended (size: {_size}). The target partition may be smaller than the source.");
|
||||
|
||||
Buffer.BlockCopy(buffer, offset, _buffer, (int)_position, count);
|
||||
_position += count;
|
||||
_validData = true;
|
||||
_dirty = true;
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
if (_writeEnabled && _dirty)
|
||||
{
|
||||
// Write all buffered data to disk
|
||||
_disk.WriteBytesAsync(_address, _buffer.AsMemory(0, (int)_size), CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
// Return the rented buffer to the pool
|
||||
if (_bufferRented)
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(_buffer);
|
||||
}
|
||||
}
|
||||
_disposed = true;
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously releases the unmanaged resources used by the <see cref="Fat32Stream"/>.
|
||||
/// </summary>
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
if (_writeEnabled && _dirty)
|
||||
{
|
||||
// Write all buffered data to disk
|
||||
await _disk.WriteBytesAsync(_address, _buffer.AsMemory(0, (int)_size), CancellationToken.None);
|
||||
}
|
||||
// Return the rented buffer to the pool
|
||||
if (_bufferRented)
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(_buffer);
|
||||
}
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A stream that returns zeros without reading from disk.
|
||||
/// Used for unallocated blocks to optimize backup performance.
|
||||
/// </summary>
|
||||
internal class Fat32ZeroStream : Stream
|
||||
{
|
||||
/// <summary>
|
||||
/// The size of the stream in bytes.
|
||||
/// </summary>
|
||||
private readonly long _size;
|
||||
|
||||
/// <summary>
|
||||
/// The shared zero buffer for this block size.
|
||||
/// </summary>
|
||||
private readonly byte[] _zeroBuffer;
|
||||
|
||||
/// <summary>
|
||||
/// The current position within the stream.
|
||||
/// </summary>
|
||||
private long _position;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the stream has been disposed.
|
||||
/// </summary>
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Fat32ZeroStream"/> class.
|
||||
/// </summary>
|
||||
/// <param name="size">The size of the stream in bytes.</param>
|
||||
public Fat32ZeroStream(int size)
|
||||
{
|
||||
if (size <= 0)
|
||||
throw new ArgumentException("Size must be positive.", nameof(size));
|
||||
|
||||
_size = size;
|
||||
_position = 0;
|
||||
_disposed = false;
|
||||
|
||||
// Get or create the shared zero buffer for this size
|
||||
_zeroBuffer = s_zeroBuffers.GetOrAdd(size, s => new byte[s]);
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanWrite => false;
|
||||
public override bool CanSeek => true;
|
||||
public override long Length => _size;
|
||||
public override long Position
|
||||
{
|
||||
get => _position;
|
||||
set
|
||||
{
|
||||
if (value < 0 || value > _size)
|
||||
throw new ArgumentOutOfRangeException(nameof(value));
|
||||
_position = value;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
// No-op
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
long newPosition = origin switch
|
||||
{
|
||||
SeekOrigin.Begin => offset,
|
||||
SeekOrigin.Current => _position + offset,
|
||||
SeekOrigin.End => _size + offset,
|
||||
_ => throw new ArgumentException("Invalid seek origin", nameof(origin))
|
||||
};
|
||||
|
||||
if (newPosition < 0 || newPosition > _size)
|
||||
throw new ArgumentOutOfRangeException(nameof(offset));
|
||||
|
||||
_position = newPosition;
|
||||
return _position;
|
||||
}
|
||||
|
||||
public override void SetLength(long value)
|
||||
{
|
||||
throw new NotSupportedException("Cannot change the length of this stream.");
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (buffer == null)
|
||||
throw new ArgumentNullException(nameof(buffer));
|
||||
if (offset < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(offset));
|
||||
if (count < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(count));
|
||||
if (buffer.Length - offset < count)
|
||||
throw new ArgumentException("Invalid offset and count for buffer length");
|
||||
|
||||
int bytesToRead = (int)Math.Min(count, _size - _position);
|
||||
if (bytesToRead <= 0)
|
||||
return 0;
|
||||
|
||||
// Copy zeros from the shared buffer
|
||||
Buffer.BlockCopy(_zeroBuffer, 0, buffer, offset, bytesToRead);
|
||||
_position += bytesToRead;
|
||||
return bytesToRead;
|
||||
}
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count)
|
||||
{
|
||||
throw new NotSupportedException("Write is not supported on this stream.");
|
||||
}
|
||||
|
||||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(Read(buffer, offset, count));
|
||||
}
|
||||
|
||||
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotSupportedException("Write is not supported on this stream.");
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
// No-op - the shared buffer is never returned
|
||||
_disposed = true;
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a parsed FAT32 Boot Sector (BIOS Parameter Block).
|
||||
/// This is a read-only struct that extracts geometry information from the boot sector.
|
||||
/// </summary>
|
||||
public readonly record struct Fat32BootSector
|
||||
{
|
||||
/// <summary>
|
||||
/// The boot sector signature at offset 510.
|
||||
/// On disk the bytes are 0x55, 0xAA; read as little-endian uint16 this is 0xAA55.
|
||||
/// </summary>
|
||||
private const ushort BootSectorSignature = 0xAA55;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to the boot sector signature.
|
||||
/// </summary>
|
||||
private const int SignatureOffset = 510;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to the filesystem type string (should contain "FAT32").
|
||||
/// </summary>
|
||||
private const int FilesystemTypeOffset = 0x52;
|
||||
|
||||
/// <summary>
|
||||
/// Length of the filesystem type string (8 bytes).
|
||||
/// </summary>
|
||||
private const int FilesystemTypeLength = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to BytesPerSector field.
|
||||
/// </summary>
|
||||
private const int BytesPerSectorOffset = 0x0B;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to SectorsPerCluster field.
|
||||
/// </summary>
|
||||
private const int SectorsPerClusterOffset = 0x0D;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to ReservedSectorCount field.
|
||||
/// </summary>
|
||||
private const int ReservedSectorCountOffset = 0x0E;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to NumberOfFats field.
|
||||
/// </summary>
|
||||
private const int NumberOfFatsOffset = 0x10;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to TotalSectors32 field.
|
||||
/// </summary>
|
||||
private const int TotalSectors32Offset = 0x20;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to FatSize32 field (sectors per FAT).
|
||||
/// </summary>
|
||||
private const int FatSize32Offset = 0x24;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to RootCluster field.
|
||||
/// </summary>
|
||||
private const int RootClusterOffset = 0x2C;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to FsInfoSector field.
|
||||
/// </summary>
|
||||
private const int FsInfoSectorOffset = 0x30;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum valid bytes per sector (512).
|
||||
/// </summary>
|
||||
private const ushort MinBytesPerSector = 512;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum valid bytes per sector (4096).
|
||||
/// </summary>
|
||||
private const ushort MaxBytesPerSector = 4096;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum valid sectors per cluster (1).
|
||||
/// </summary>
|
||||
private const byte MinSectorsPerCluster = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum valid sectors per cluster (128).
|
||||
/// </summary>
|
||||
private const byte MaxSectorsPerCluster = 128;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum required buffer size (512 bytes for boot sector).
|
||||
/// </summary>
|
||||
private const int MinimumBufferSize = 512;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of bytes per sector (typically 512, 1024, 2048, or 4096).
|
||||
/// </summary>
|
||||
public ushort BytesPerSector { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of sectors per cluster (power of 2: 1, 2, 4, 8, 16, 32, 64, 128).
|
||||
/// </summary>
|
||||
public byte SectorsPerCluster { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of reserved sectors (includes boot sector and FSInfo).
|
||||
/// </summary>
|
||||
public ushort ReservedSectorCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of FAT copies (typically 2).
|
||||
/// </summary>
|
||||
public byte NumberOfFats { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of sectors in the volume (32-bit value).
|
||||
/// </summary>
|
||||
public uint TotalSectors32 { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of sectors per FAT.
|
||||
/// </summary>
|
||||
public uint FatSize32 { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the root directory cluster number (typically 2).
|
||||
/// </summary>
|
||||
public uint RootCluster { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the FSInfo sector number.
|
||||
/// </summary>
|
||||
public ushort FsInfoSector { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the size of a cluster in bytes.
|
||||
/// </summary>
|
||||
public int ClusterSize => BytesPerSector * SectorsPerCluster;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the byte offset from the partition start to the first FAT.
|
||||
/// </summary>
|
||||
public long FatStartOffset => (long)ReservedSectorCount * BytesPerSector;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the byte offset from the partition start to the data region.
|
||||
/// </summary>
|
||||
public long DataStartOffset => (long)(ReservedSectorCount + NumberOfFats * FatSize32) * BytesPerSector;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of data clusters in the volume.
|
||||
/// </summary>
|
||||
public uint TotalDataClusters => (uint)((TotalSectors32 - (ReservedSectorCount + NumberOfFats * FatSize32)) / SectorsPerCluster);
|
||||
|
||||
/// <summary>
|
||||
/// Minimum valid total sectors for a FAT32 volume (1 MB = 2048 sectors at 512 bytes).
|
||||
/// </summary>
|
||||
private const uint MinTotalSectors = 2048;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum valid FAT size in sectors (for a small volume).
|
||||
/// </summary>
|
||||
private const uint MinFatSize = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum valid FAT size in sectors (for very large volumes, e.g., 2TB with 4KB sectors).
|
||||
/// </summary>
|
||||
private const uint MaxFatSize = 16777215;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Fat32BootSector"/> struct by parsing the provided boot sector data.
|
||||
/// </summary>
|
||||
/// <param name="bootSectorData">The boot sector data (must be at least 512 bytes).</param>
|
||||
/// <exception cref="ArgumentException">Thrown when the buffer is too small or validation fails.</exception>
|
||||
public Fat32BootSector(ReadOnlySpan<byte> bootSectorData)
|
||||
{
|
||||
if (bootSectorData.Length < MinimumBufferSize)
|
||||
throw new ArgumentException($"Boot sector data must be at least {MinimumBufferSize} bytes.", nameof(bootSectorData));
|
||||
|
||||
// Validate boot sector signature (bytes 0x55, 0xAA at offset 510; 0xAA55 as little-endian uint16)
|
||||
var signature = BinaryPrimitives.ReadUInt16LittleEndian(bootSectorData.Slice(SignatureOffset, 2));
|
||||
if (signature != BootSectorSignature)
|
||||
throw new ArgumentException($"Invalid boot sector signature: expected 0x{BootSectorSignature:X4}, got 0x{signature:X4}.", nameof(bootSectorData));
|
||||
|
||||
// Parse fields from the boot sector
|
||||
BytesPerSector = BinaryPrimitives.ReadUInt16LittleEndian(bootSectorData.Slice(BytesPerSectorOffset, 2));
|
||||
SectorsPerCluster = bootSectorData[SectorsPerClusterOffset];
|
||||
ReservedSectorCount = BinaryPrimitives.ReadUInt16LittleEndian(bootSectorData.Slice(ReservedSectorCountOffset, 2));
|
||||
NumberOfFats = bootSectorData[NumberOfFatsOffset];
|
||||
TotalSectors32 = BinaryPrimitives.ReadUInt32LittleEndian(bootSectorData.Slice(TotalSectors32Offset, 4));
|
||||
FatSize32 = BinaryPrimitives.ReadUInt32LittleEndian(bootSectorData.Slice(FatSize32Offset, 4));
|
||||
RootCluster = BinaryPrimitives.ReadUInt32LittleEndian(bootSectorData.Slice(RootClusterOffset, 4));
|
||||
FsInfoSector = BinaryPrimitives.ReadUInt16LittleEndian(bootSectorData.Slice(FsInfoSectorOffset, 2));
|
||||
|
||||
// Validate BytesPerSector is a power of 2 (512, 1024, 2048, or 4096)
|
||||
if (!IsPowerOfTwo(BytesPerSector) || BytesPerSector < MinBytesPerSector || BytesPerSector > MaxBytesPerSector)
|
||||
throw new ArgumentException($"Invalid BytesPerSector: {BytesPerSector}. Must be a power of 2 between {MinBytesPerSector} and {MaxBytesPerSector}.", nameof(bootSectorData));
|
||||
|
||||
// Validate SectorsPerCluster is a power of 2 (1, 2, 4, 8, 16, 32, 64, or 128)
|
||||
if (!IsPowerOfTwo(SectorsPerCluster) || SectorsPerCluster < MinSectorsPerCluster || SectorsPerCluster > MaxSectorsPerCluster)
|
||||
throw new ArgumentException($"Invalid SectorsPerCluster: {SectorsPerCluster}. Must be a power of 2 between {MinSectorsPerCluster} and {MaxSectorsPerCluster}.", nameof(bootSectorData));
|
||||
|
||||
// Validate NumberOfFats >= 1
|
||||
if (NumberOfFats < 1)
|
||||
throw new ArgumentException($"Invalid NumberOfFats: {NumberOfFats}. Must be at least 1.", nameof(bootSectorData));
|
||||
|
||||
// Validate TotalSectors32 is reasonable (at least 1 MB worth of sectors)
|
||||
if (TotalSectors32 < MinTotalSectors)
|
||||
throw new ArgumentException($"Invalid TotalSectors32: {TotalSectors32}. Must be at least {MinTotalSectors} (1 MB).", nameof(bootSectorData));
|
||||
|
||||
// Validate FatSize32 is reasonable
|
||||
if (FatSize32 < MinFatSize || FatSize32 > MaxFatSize)
|
||||
throw new ArgumentException($"Invalid FatSize32: {FatSize32}. Must be between {MinFatSize} and {MaxFatSize}.", nameof(bootSectorData));
|
||||
|
||||
// Validate RootCluster (typically 2, but allow any reasonable value >= 2)
|
||||
if (RootCluster < 2)
|
||||
throw new ArgumentException($"Invalid RootCluster: {RootCluster}. Must be at least 2.", nameof(bootSectorData));
|
||||
|
||||
// Validate ReservedSectorCount is reasonable (typically 0-65535)
|
||||
if (ReservedSectorCount < 1 || ReservedSectorCount > 65535)
|
||||
throw new ArgumentException($"Invalid ReservedSectorCount: {ReservedSectorCount}. Must be between 1 and 65535.", nameof(bootSectorData));
|
||||
|
||||
// Validate the filesystem type string at offset 0x52 contains "FAT32" (exact match)
|
||||
var fsType = Encoding.ASCII.GetString(bootSectorData.Slice(FilesystemTypeOffset, FilesystemTypeLength));
|
||||
if (fsType != "FAT32 " && fsType != "FAT32")
|
||||
throw new ArgumentException($"Invalid filesystem type: expected 'FAT32 ' or 'FAT32' in filesystem type string, got '{fsType}'.", nameof(bootSectorData));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a cluster number to its byte offset in the data region.
|
||||
/// </summary>
|
||||
/// <param name="clusterNumber">The cluster number (clusters are 2-indexed).</param>
|
||||
/// <returns>The byte offset from the partition start.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when cluster number is less than 2.</exception>
|
||||
public long ClusterToByteOffset(uint clusterNumber)
|
||||
{
|
||||
if (clusterNumber < 2)
|
||||
throw new ArgumentException("Cluster numbers start at 2.", nameof(clusterNumber));
|
||||
|
||||
return DataStartOffset + (clusterNumber - 2) * ClusterSize;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a value is a power of 2.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to check.</param>
|
||||
/// <returns>True if the value is a power of 2; otherwise, false.</returns>
|
||||
private static bool IsPowerOfTwo(ushort value) => value != 0 && (value & (value - 1)) == 0;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a value is a power of 2.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to check.</param>
|
||||
/// <returns>True if the value is a power of 2; otherwise, false.</returns>
|
||||
private static bool IsPowerOfTwo(byte value) => value != 0 && (value & (value - 1)) == 0;
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Proprietary.DiskImage.Partition;
|
||||
|
||||
namespace Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
|
||||
|
||||
/// <summary>
|
||||
/// Walks the FAT32 directory tree to extract file metadata and build a cluster-to-timestamp map.
|
||||
/// </summary>
|
||||
internal class Fat32DirectoryWalker
|
||||
{
|
||||
/// <summary>
|
||||
/// Attribute indicating a directory entry.
|
||||
/// </summary>
|
||||
private const byte ATTR_DIRECTORY = 0x10;
|
||||
|
||||
/// <summary>
|
||||
/// Attribute indicating a long filename (LFN) entry.
|
||||
/// </summary>
|
||||
private const byte ATTR_LONG_FILENAME = 0x0F;
|
||||
|
||||
/// <summary>
|
||||
/// Attribute mask for the volume label.
|
||||
/// </summary>
|
||||
private const byte ATTR_VOLUME_ID = 0x08;
|
||||
|
||||
/// <summary>
|
||||
/// First byte indicating a deleted directory entry.
|
||||
/// </summary>
|
||||
private const byte DELETED_ENTRY_MARKER = 0xE5;
|
||||
|
||||
/// <summary>
|
||||
/// First byte indicating end of directory (no more entries follow).
|
||||
/// </summary>
|
||||
private const byte END_OF_DIRECTORY_MARKER = 0x00;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to the attributes field in a directory entry.
|
||||
/// </summary>
|
||||
private const int ENTRY_ATTRIBUTES_OFFSET = 0x0B;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to the creation time field in a directory entry.
|
||||
/// </summary>
|
||||
private const int ENTRY_CREATION_TIME_OFFSET = 0x0E;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to the creation date field in a directory entry.
|
||||
/// </summary>
|
||||
private const int ENTRY_CREATION_DATE_OFFSET = 0x10;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to the first cluster high word in a directory entry.
|
||||
/// </summary>
|
||||
private const int ENTRY_CLUSTER_HIGH_OFFSET = 0x14;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to the last write time field in a directory entry.
|
||||
/// </summary>
|
||||
private const int ENTRY_WRITE_TIME_OFFSET = 0x16;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to the last write date field in a directory entry.
|
||||
/// </summary>
|
||||
private const int ENTRY_WRITE_DATE_OFFSET = 0x18;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to the first cluster low word in a directory entry.
|
||||
/// </summary>
|
||||
private const int ENTRY_CLUSTER_LOW_OFFSET = 0x1A;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to the file size field in a directory entry.
|
||||
/// </summary>
|
||||
private const int ENTRY_FILE_SIZE_OFFSET = 0x1C;
|
||||
|
||||
/// <summary>
|
||||
/// Size of a single directory entry in bytes.
|
||||
/// </summary>
|
||||
private const int DIRECTORY_ENTRY_SIZE = 32;
|
||||
|
||||
/// <summary>
|
||||
/// The boot sector containing FAT32 geometry information.
|
||||
/// </summary>
|
||||
private readonly Fat32BootSector m_bootSector;
|
||||
|
||||
/// <summary>
|
||||
/// The FAT table for looking up cluster chains.
|
||||
/// </summary>
|
||||
private readonly Fat32Table m_fatTable;
|
||||
|
||||
/// <summary>
|
||||
/// The partition for reading cluster data.
|
||||
/// </summary>
|
||||
private readonly IPartition m_partition;
|
||||
|
||||
/// <summary>
|
||||
/// Maps each allocated cluster to the modification time of the file that owns it.
|
||||
/// </summary>
|
||||
private readonly Dictionary<uint, DateTime> m_clusterToTimestampMap;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Fat32DirectoryWalker"/> class and walks the directory tree.
|
||||
/// </summary>
|
||||
/// <param name="partition">The partition containing the FAT32 filesystem.</param>
|
||||
/// <param name="bootSector">The parsed boot sector containing FAT32 geometry.</param>
|
||||
/// <param name="fatTable">The FAT table for cluster chain lookups.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown if any parameter is null.</exception>
|
||||
public Fat32DirectoryWalker(IPartition partition, Fat32BootSector bootSector, Fat32Table fatTable, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(partition);
|
||||
ArgumentNullException.ThrowIfNull(fatTable);
|
||||
|
||||
m_partition = partition;
|
||||
m_bootSector = bootSector;
|
||||
m_fatTable = fatTable;
|
||||
m_clusterToTimestampMap = [];
|
||||
|
||||
// Walk the directory tree starting from the root cluster
|
||||
WalkDirectoryTree(bootSector.RootCluster, DateTime.UnixEpoch, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Fat32DirectoryWalker"/> class for testing.
|
||||
/// This constructor allows direct specification of cluster data without reading from a partition.
|
||||
/// </summary>
|
||||
/// <param name="bootSector">The parsed boot sector containing FAT32 geometry.</param>
|
||||
/// <param name="fatTable">The FAT table for cluster chain lookups.</param>
|
||||
/// <param name="clusterData">A function that returns cluster data for a given cluster number.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown if any parameter is null.</exception>
|
||||
internal Fat32DirectoryWalker(Fat32BootSector bootSector, Fat32Table fatTable, Func<uint, byte[]> clusterData)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fatTable);
|
||||
ArgumentNullException.ThrowIfNull(clusterData);
|
||||
|
||||
m_partition = null!;
|
||||
m_bootSector = bootSector;
|
||||
m_fatTable = fatTable;
|
||||
m_clusterToTimestampMap = [];
|
||||
|
||||
// Walk the directory tree starting from the root cluster using the provided cluster data
|
||||
WalkDirectoryTreeWithData(bootSector.RootCluster, DateTime.UnixEpoch, clusterData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cluster-to-timestamp map. Each allocated cluster is mapped to the modification time
|
||||
/// of the file or directory that owns it.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<uint, DateTime> ClusterToTimestampMap => m_clusterToTimestampMap;
|
||||
|
||||
/// <summary>
|
||||
/// Recursively walks the directory tree starting from the specified cluster.
|
||||
/// </summary>
|
||||
/// <param name="startCluster">The starting cluster number (root or subdirectory).</param>
|
||||
/// <param name="directoryTimestamp">The modification timestamp of the parent directory.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
private void WalkDirectoryTree(uint startCluster, DateTime directoryTimestamp, CancellationToken cancellationToken)
|
||||
{
|
||||
using var stream = m_partition.OpenReadAsync(cancellationToken).Result;
|
||||
|
||||
byte[] clusterData(uint clusterNum)
|
||||
{
|
||||
var data = new byte[m_bootSector.ClusterSize];
|
||||
var offset = m_bootSector.ClusterToByteOffset(clusterNum);
|
||||
stream.Seek(offset, SeekOrigin.Begin);
|
||||
stream.ReadExactly(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
WalkDirectoryTreeWithData(startCluster, directoryTimestamp, clusterData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively walks the directory tree using provided cluster data.
|
||||
/// </summary>
|
||||
/// <param name="startCluster">The starting cluster number (root or subdirectory).</param>
|
||||
/// <param name="directoryTimestamp">The modification timestamp of the parent directory.</param>
|
||||
/// <param name="clusterData">Function to get cluster data.</param>
|
||||
private void WalkDirectoryTreeWithData(uint startCluster, DateTime directoryTimestamp, Func<uint, byte[]> clusterData)
|
||||
{
|
||||
// Get the cluster chain for this directory
|
||||
var clusterChain = m_fatTable.GetClusterChain(startCluster);
|
||||
|
||||
foreach (var cluster in clusterChain)
|
||||
{
|
||||
// Mark the directory's own cluster with its timestamp
|
||||
if (!m_clusterToTimestampMap.ContainsKey(cluster))
|
||||
{
|
||||
m_clusterToTimestampMap[cluster] = directoryTimestamp;
|
||||
}
|
||||
|
||||
// Read and process the directory entries in this cluster
|
||||
var data = clusterData(cluster);
|
||||
ProcessDirectoryEntries(data, directoryTimestamp, clusterData);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes directory entries from raw cluster data.
|
||||
/// </summary>
|
||||
/// <param name="data">The raw cluster data containing directory entries.</param>
|
||||
/// <param name="directoryTimestamp">The modification timestamp of the parent directory.</param>
|
||||
/// <param name="clusterData">Function to get cluster data for subdirectories.</param>
|
||||
private void ProcessDirectoryEntries(byte[] data, DateTime directoryTimestamp, Func<uint, byte[]> clusterData)
|
||||
{
|
||||
var entryCount = data.Length / DIRECTORY_ENTRY_SIZE;
|
||||
|
||||
for (int i = 0; i < entryCount; i++)
|
||||
{
|
||||
var entryOffset = i * DIRECTORY_ENTRY_SIZE;
|
||||
var entrySpan = data.AsSpan(entryOffset, DIRECTORY_ENTRY_SIZE);
|
||||
|
||||
// Check for end of directory marker
|
||||
if (entrySpan[0] == END_OF_DIRECTORY_MARKER)
|
||||
break;
|
||||
|
||||
// Skip deleted entries
|
||||
if (entrySpan[0] == DELETED_ENTRY_MARKER)
|
||||
continue;
|
||||
|
||||
// Get the attribute byte
|
||||
var attributes = entrySpan[ENTRY_ATTRIBUTES_OFFSET];
|
||||
|
||||
// Skip long filename entries
|
||||
if (attributes == ATTR_LONG_FILENAME)
|
||||
continue;
|
||||
|
||||
// Skip volume labels
|
||||
if ((attributes & ATTR_VOLUME_ID) == ATTR_VOLUME_ID)
|
||||
continue;
|
||||
|
||||
// Check if this is a directory
|
||||
var isDirectory = (attributes & ATTR_DIRECTORY) == ATTR_DIRECTORY;
|
||||
|
||||
// Extract the starting cluster number
|
||||
var clusterHigh = BinaryPrimitives.ReadUInt16LittleEndian(entrySpan.Slice(ENTRY_CLUSTER_HIGH_OFFSET, 2));
|
||||
var clusterLow = BinaryPrimitives.ReadUInt16LittleEndian(entrySpan.Slice(ENTRY_CLUSTER_LOW_OFFSET, 2));
|
||||
var startCluster = (uint)((clusterHigh << 16) | clusterLow);
|
||||
|
||||
// Skip entries with invalid cluster numbers (0 means unallocated for files)
|
||||
if (startCluster < 2)
|
||||
continue;
|
||||
|
||||
// Extract the filename to check for . and .. entries
|
||||
var filename = GetShortFilename(entrySpan);
|
||||
|
||||
// Skip . and .. entries to avoid infinite recursion
|
||||
if (filename == "." || filename == "..")
|
||||
continue;
|
||||
|
||||
// Extract the last write timestamp
|
||||
var lastWriteTime = ParseDateTime(
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(entrySpan.Slice(ENTRY_WRITE_DATE_OFFSET, 2)),
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(entrySpan.Slice(ENTRY_WRITE_TIME_OFFSET, 2)));
|
||||
|
||||
// Use the file's own timestamp, or fall back to the directory's timestamp
|
||||
var fileTimestamp = lastWriteTime > DateTime.UnixEpoch ? lastWriteTime : directoryTimestamp;
|
||||
|
||||
if (isDirectory)
|
||||
{
|
||||
// Recursively process subdirectories (only if the cluster is valid)
|
||||
if (startCluster < m_fatTable.TotalClusters)
|
||||
{
|
||||
WalkDirectoryTreeWithData(startCluster, fileTimestamp, clusterData);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// For files, map all clusters in the file's chain to the file's timestamp
|
||||
try
|
||||
{
|
||||
// Only process if the start cluster is within valid range
|
||||
if (startCluster >= m_fatTable.TotalClusters)
|
||||
continue;
|
||||
|
||||
var fileClusters = m_fatTable.GetClusterChain(startCluster);
|
||||
foreach (var fileCluster in fileClusters)
|
||||
{
|
||||
// For overlapping clusters, use the most recent timestamp
|
||||
if (!m_clusterToTimestampMap.TryGetValue(fileCluster, out var existingTimestamp) ||
|
||||
fileTimestamp > existingTimestamp)
|
||||
{
|
||||
m_clusterToTimestampMap[fileCluster] = fileTimestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// If we can't get the cluster chain (e.g., circular chain), skip this file
|
||||
// but still mark the starting cluster if it's valid
|
||||
if (startCluster < m_fatTable.TotalClusters)
|
||||
{
|
||||
if (!m_clusterToTimestampMap.TryGetValue(startCluster, out var existingTimestamp) ||
|
||||
fileTimestamp > existingTimestamp)
|
||||
{
|
||||
m_clusterToTimestampMap[startCluster] = fileTimestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the short filename (8.3 format) from a directory entry.
|
||||
/// </summary>
|
||||
/// <param name="entry">The directory entry span.</param>
|
||||
/// <returns>The filename string.</returns>
|
||||
private static string GetShortFilename(ReadOnlySpan<byte> entry)
|
||||
{
|
||||
// Short filename is 11 bytes: 8 for name, 3 for extension
|
||||
var name = entry.Slice(0, 8);
|
||||
var ext = entry.Slice(8, 3);
|
||||
|
||||
// Trim spaces and construct the filename
|
||||
var nameStr = System.Text.Encoding.ASCII.GetString(name).TrimEnd();
|
||||
var extStr = System.Text.Encoding.ASCII.GetString(ext).TrimEnd();
|
||||
|
||||
if (string.IsNullOrEmpty(extStr))
|
||||
return nameStr;
|
||||
|
||||
return $"{nameStr}.{extStr}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses FAT32 date and time fields into a DateTime.
|
||||
/// </summary>
|
||||
/// <param name="date">The date field (bits 15-9 = year-1980, bits 8-5 = month, bits 4-0 = day).</param>
|
||||
/// <param name="time">The time field (bits 15-11 = hours, bits 10-5 = minutes, bits 4-0 = seconds/2).</param>
|
||||
/// <returns>The parsed DateTime in UTC.</returns>
|
||||
internal static DateTime ParseDateTime(ushort date, ushort time)
|
||||
{
|
||||
// If both date and time are 0, return Unix epoch
|
||||
if (date == 0 && time == 0)
|
||||
return DateTime.UnixEpoch;
|
||||
|
||||
// Extract date components
|
||||
var year = 1980 + ((date >> 9) & 0x7F);
|
||||
var month = (date >> 5) & 0x0F;
|
||||
var day = date & 0x1F;
|
||||
|
||||
// Extract time components
|
||||
var hour = (time >> 11) & 0x1F;
|
||||
var minute = (time >> 5) & 0x3F;
|
||||
var second = (time & 0x1F) * 2;
|
||||
|
||||
// Validate the values
|
||||
if (year < 1980 || year > 2107)
|
||||
return DateTime.UnixEpoch;
|
||||
if (month < 1 || month > 12)
|
||||
return DateTime.UnixEpoch;
|
||||
if (day < 1 || day > 31)
|
||||
return DateTime.UnixEpoch;
|
||||
if (hour > 23)
|
||||
return DateTime.UnixEpoch;
|
||||
if (minute > 59)
|
||||
return DateTime.UnixEpoch;
|
||||
if (second > 59)
|
||||
return DateTime.UnixEpoch;
|
||||
|
||||
try
|
||||
{
|
||||
// Create DateTime in UTC (FAT32 doesn't store timezone info)
|
||||
var localTime = new DateTime((int)year, (int)month, (int)day, (int)hour, (int)minute, (int)second, DateTimeKind.Utc);
|
||||
return localTime;
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return DateTime.UnixEpoch;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Proprietary.DiskImage.Partition;
|
||||
|
||||
namespace Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and provides access to the FAT32 File Allocation Table (FAT).
|
||||
/// This class reads the FAT from disk and provides methods to query cluster allocation state
|
||||
/// and follow cluster chains.
|
||||
/// </summary>
|
||||
internal class Fat32Table
|
||||
{
|
||||
/// <summary>
|
||||
/// Value indicating a free cluster.
|
||||
/// </summary>
|
||||
public const uint FREE_CLUSTER = 0x00000000;
|
||||
|
||||
/// <summary>
|
||||
/// Value indicating a bad cluster.
|
||||
/// </summary>
|
||||
public const uint BAD_CLUSTER = 0x0FFFFFF7;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum value indicating end of cluster chain.
|
||||
/// </summary>
|
||||
public const uint EOC_MIN = 0x0FFFFFF8;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum value indicating end of cluster chain.
|
||||
/// </summary>
|
||||
public const uint EOC_MAX = 0x0FFFFFFF;
|
||||
|
||||
/// <summary>
|
||||
/// Mask for extracting the 28-bit FAT entry value.
|
||||
/// </summary>
|
||||
public const uint ENTRY_MASK = 0x0FFFFFFF;
|
||||
|
||||
/// <summary>
|
||||
/// The boot sector containing FAT geometry information.
|
||||
/// </summary>
|
||||
private readonly Fat32BootSector m_bootSector;
|
||||
|
||||
/// <summary>
|
||||
/// The raw FAT data read from disk.
|
||||
/// </summary>
|
||||
private readonly byte[] m_fatData;
|
||||
|
||||
/// <summary>
|
||||
/// Bitmap for fast lookup of allocated clusters.
|
||||
/// Index 0 corresponds to cluster 0 (reserved), index 2 corresponds to cluster 2 (first data cluster).
|
||||
/// </summary>
|
||||
private readonly BitArray m_allocationBitmap;
|
||||
|
||||
/// <summary>
|
||||
/// Total number of clusters in the FAT (including reserved clusters 0 and 1).
|
||||
/// </summary>
|
||||
private readonly uint m_totalClusters;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Fat32Table"/> class by reading the FAT from disk.
|
||||
/// </summary>
|
||||
/// <param name="partition">The partition containing the FAT32 filesystem.</param>
|
||||
/// <param name="bootSector">The parsed boot sector containing FAT geometry.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown if partition or bootSector is null.</exception>
|
||||
public Fat32Table(IPartition partition, Fat32BootSector bootSector, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(partition);
|
||||
|
||||
m_bootSector = bootSector;
|
||||
|
||||
// Calculate FAT size in bytes
|
||||
var fatSizeInBytes = (long)bootSector.FatSize32 * bootSector.BytesPerSector;
|
||||
|
||||
// Calculate total number of FAT entries
|
||||
m_totalClusters = (uint)(fatSizeInBytes / 4);
|
||||
|
||||
// Read the entire FAT into memory
|
||||
m_fatData = new byte[fatSizeInBytes];
|
||||
|
||||
using var stream = partition.OpenReadAsync(cancellationToken).Result;
|
||||
stream.Seek(bootSector.FatStartOffset, SeekOrigin.Begin);
|
||||
stream.ReadExactly(m_fatData);
|
||||
|
||||
// Build the allocation bitmap
|
||||
m_allocationBitmap = BuildAllocationBitmap();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Fat32Table"/> class from raw FAT data.
|
||||
/// This constructor is primarily used for testing.
|
||||
/// </summary>
|
||||
/// <param name="bootSector">The parsed boot sector containing FAT geometry.</param>
|
||||
/// <param name="fatData">The raw FAT data.</param>
|
||||
public Fat32Table(Fat32BootSector bootSector, byte[] fatData)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fatData);
|
||||
|
||||
m_bootSector = bootSector;
|
||||
m_fatData = fatData;
|
||||
m_totalClusters = (uint)(fatData.Length / 4);
|
||||
m_allocationBitmap = BuildAllocationBitmap();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of clusters in the FAT.
|
||||
/// </summary>
|
||||
public uint TotalClusters => m_totalClusters;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of allocated (in-use) clusters.
|
||||
/// </summary>
|
||||
public uint AllocatedClusters
|
||||
{
|
||||
get
|
||||
{
|
||||
var count = 0;
|
||||
foreach (bool bit in m_allocationBitmap)
|
||||
{
|
||||
if (bit)
|
||||
count++;
|
||||
}
|
||||
return (uint)count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of free clusters.
|
||||
/// </summary>
|
||||
public uint FreeClusters => m_totalClusters - AllocatedClusters;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a cluster is allocated (in use).
|
||||
/// </summary>
|
||||
/// <param name="clusterNumber">The cluster number to check.</param>
|
||||
/// <returns>True if the cluster is allocated (in use, not free, not bad); otherwise, false.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown if cluster number is out of range.</exception>
|
||||
public bool IsClusterAllocated(uint clusterNumber)
|
||||
{
|
||||
if (clusterNumber >= m_totalClusters)
|
||||
throw new ArgumentOutOfRangeException(nameof(clusterNumber), $"Cluster number {clusterNumber} is out of range. Valid range is 0 to {m_totalClusters - 1}.");
|
||||
|
||||
return m_allocationBitmap[(int)clusterNumber];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next cluster in a cluster chain.
|
||||
/// </summary>
|
||||
/// <param name="clusterNumber">The current cluster number.</param>
|
||||
/// <returns>The next cluster number in the chain, or the end-of-chain marker if this is the last cluster.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown if cluster number is out of range.</exception>
|
||||
public uint GetNextCluster(uint clusterNumber)
|
||||
{
|
||||
if (clusterNumber >= m_totalClusters)
|
||||
throw new ArgumentOutOfRangeException(nameof(clusterNumber), $"Cluster number {clusterNumber} is out of range. Valid range is 0 to {m_totalClusters - 1}.");
|
||||
|
||||
var entry = GetFatEntry(clusterNumber);
|
||||
|
||||
// Return the masked entry value (only lower 28 bits are valid)
|
||||
return entry & ENTRY_MASK;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full cluster chain starting from the specified cluster.
|
||||
/// </summary>
|
||||
/// <param name="startCluster">The starting cluster number.</param>
|
||||
/// <returns>A list of cluster numbers in the chain, including the start cluster.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown if start cluster is out of range.</exception>
|
||||
/// <exception cref="InvalidOperationException">Thrown if a circular chain is detected.</exception>
|
||||
public List<uint> GetClusterChain(uint startCluster)
|
||||
{
|
||||
if (startCluster >= m_totalClusters)
|
||||
throw new ArgumentOutOfRangeException(nameof(startCluster), $"Cluster number {startCluster} is out of range. Valid range is 0 to {m_totalClusters - 1}.");
|
||||
|
||||
var chain = new List<uint>();
|
||||
var visited = new HashSet<uint>();
|
||||
var currentCluster = startCluster;
|
||||
|
||||
while (true)
|
||||
{
|
||||
// Check for circular chain
|
||||
if (!visited.Add(currentCluster))
|
||||
throw new InvalidOperationException($"Circular cluster chain detected at cluster {currentCluster}.");
|
||||
|
||||
chain.Add(currentCluster);
|
||||
|
||||
var nextCluster = GetNextCluster(currentCluster);
|
||||
|
||||
// Check for end of chain
|
||||
if (IsEndOfChain(nextCluster))
|
||||
break;
|
||||
|
||||
// Check for bad or free cluster in the middle of a chain
|
||||
if (nextCluster == BAD_CLUSTER || nextCluster == FREE_CLUSTER)
|
||||
throw new InvalidOperationException($"Invalid cluster {nextCluster:X} found in chain at cluster {currentCluster}.");
|
||||
|
||||
currentCluster = nextCluster;
|
||||
}
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw FAT entry value at the specified cluster index.
|
||||
/// </summary>
|
||||
/// <param name="clusterNumber">The cluster number.</param>
|
||||
/// <returns>The raw 32-bit FAT entry value.</returns>
|
||||
private uint GetFatEntry(uint clusterNumber)
|
||||
{
|
||||
var offset = (int)(clusterNumber * 4);
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(m_fatData.AsSpan(offset, 4));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the cluster allocation bitmap from the FAT data.
|
||||
/// </summary>
|
||||
/// <returns>A BitArray where each bit indicates whether the corresponding cluster is allocated.</returns>
|
||||
private BitArray BuildAllocationBitmap()
|
||||
{
|
||||
var bitmap = new BitArray((int)m_totalClusters);
|
||||
|
||||
for (uint i = 0; i < m_totalClusters; i++)
|
||||
{
|
||||
var entry = GetFatEntry(i) & ENTRY_MASK;
|
||||
|
||||
// A cluster is allocated if it's not free and not bad
|
||||
// EOC markers and valid next-cluster values all indicate allocation
|
||||
bool isAllocated = entry != FREE_CLUSTER && entry != BAD_CLUSTER;
|
||||
bitmap[(int)i] = isAllocated;
|
||||
}
|
||||
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a FAT entry value indicates end of cluster chain.
|
||||
/// </summary>
|
||||
/// <param name="entry">The FAT entry value.</param>
|
||||
/// <returns>True if the entry indicates end of chain; otherwise, false.</returns>
|
||||
private static bool IsEndOfChain(uint entry)
|
||||
{
|
||||
return entry >= EOC_MIN && entry <= EOC_MAX;
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,12 @@ public interface IFile
|
||||
/// Gets a value indicating whether this is a directory.
|
||||
/// </summary>
|
||||
bool IsDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last modification timestamp of the file.
|
||||
/// Returns null if the timestamp is not available.
|
||||
/// </summary>
|
||||
DateTime? LastModified { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -40,6 +40,9 @@ public class UnknownFilesystemFile : IFile
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsDirectory => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTime? LastModified => DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -49,7 +52,7 @@ public class UnknownFilesystemFile : IFile
|
||||
internal class UnknownFilesystem : IFilesystem
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public FileSystemType Type => FileSystemType.Unknown;
|
||||
public FileSystemType Type { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IPartition Partition { get => m_partition; }
|
||||
@@ -74,13 +77,15 @@ internal class UnknownFilesystem : IFilesystem
|
||||
/// </summary>
|
||||
/// <param name="partition">The parent partition.</param>
|
||||
/// <param name="blockSize">The block size for reading/writing (default is 1MB).</param>
|
||||
/// <param name="overrideType">Optional filesystem type to report (defaults to Unknown). Used when falling back from a specific filesystem type.</param>
|
||||
/// <exception cref="ArgumentException">Thrown if block size is invalid.</exception>
|
||||
public UnknownFilesystem(IPartition partition, int blockSize = 1024 * 1024)
|
||||
public UnknownFilesystem(IPartition partition, int blockSize = 1024 * 1024, FileSystemType overrideType = FileSystemType.Unknown)
|
||||
{
|
||||
m_partition = partition;
|
||||
if (blockSize < 0 || blockSize % partition.PartitionTable.RawDisk?.SectorSize != 0)
|
||||
throw new ArgumentException("Block size must be non-negative and a multiple of the partition sector size.", nameof(blockSize));
|
||||
m_blockSize = blockSize;
|
||||
Type = overrideType;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -211,11 +216,19 @@ internal class UnknownFilesystem : IFilesystem
|
||||
throw new ArgumentException("The specified file is a directory.", nameof(file));
|
||||
|
||||
long address = unknownFile.Address ?? throw new ArgumentException("File address is required.", nameof(file));
|
||||
long size = unknownFile.Size;
|
||||
long requestedSize = unknownFile.Size;
|
||||
|
||||
BoundsCheck(address, size);
|
||||
BoundsCheck(address, requestedSize);
|
||||
|
||||
return new UnknownFilesystemStream(m_partition.PartitionTable.RawDisk!, m_partition.StartOffset + address, size, readEnabled: false, writeEnabled: true);
|
||||
// Calculate actual available size based on target partition
|
||||
// This ensures we fail fast if the target is too small
|
||||
long actualSize = Math.Min(requestedSize, m_partition.Size - address);
|
||||
if (actualSize < requestedSize)
|
||||
throw new IOException($"Target partition is too small to write block at address 0x{address:X}. " +
|
||||
$"Requested size: {requestedSize} bytes, Available: {actualSize} bytes. " +
|
||||
$"The target disk may be smaller than the source disk.");
|
||||
|
||||
return new UnknownFilesystemStream(m_partition.PartitionTable.RawDisk!, m_partition.StartOffset + address, actualSize, readEnabled: false, writeEnabled: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -237,11 +250,19 @@ internal class UnknownFilesystem : IFilesystem
|
||||
throw new ArgumentException("The specified file is a directory.", nameof(file));
|
||||
|
||||
long address = unknownFile.Address ?? throw new ArgumentException("File address is required.", nameof(file));
|
||||
long size = unknownFile.Size;
|
||||
long requestedSize = unknownFile.Size;
|
||||
|
||||
BoundsCheck(address, size);
|
||||
BoundsCheck(address, requestedSize);
|
||||
|
||||
return new UnknownFilesystemStream(m_partition.PartitionTable.RawDisk!, m_partition.StartOffset + address, size, readEnabled: true, writeEnabled: true);
|
||||
// Calculate actual available size based on target partition
|
||||
// This ensures we fail fast if the target is too small
|
||||
long actualSize = Math.Min(requestedSize, m_partition.Size - address);
|
||||
if (actualSize < requestedSize)
|
||||
throw new IOException($"Target partition is too small to write block at address 0x{address:X}. " +
|
||||
$"Requested size: {requestedSize} bytes, Available: {actualSize} bytes. " +
|
||||
$"The target disk may be smaller than the source disk.");
|
||||
|
||||
return new UnknownFilesystemStream(m_partition.PartitionTable.RawDisk!, m_partition.StartOffset + address, actualSize, readEnabled: true, writeEnabled: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -447,8 +468,8 @@ internal class UnknownFilesystem : IFilesystem
|
||||
if (buffer.Length - offset < count)
|
||||
throw new ArgumentException("Invalid offset and count for buffer length");
|
||||
|
||||
if (_position + count > _size)
|
||||
throw new ArgumentException("Write would exceed stream size");
|
||||
if (_position >= _size)
|
||||
throw new IOException($"Cannot write at position {_position}: stream has ended (size: {_size}). The target partition may be smaller than the source.");
|
||||
|
||||
Buffer.BlockCopy(buffer, offset, _buffer, (int)_position, count);
|
||||
_position += count;
|
||||
@@ -501,8 +522,8 @@ internal class UnknownFilesystem : IFilesystem
|
||||
if (buffer.Length - offset < count)
|
||||
throw new ArgumentException("Invalid offset and count for buffer length");
|
||||
|
||||
if (_position + count > _size)
|
||||
throw new ArgumentException("Write would exceed stream size");
|
||||
if (_position >= _size)
|
||||
throw new IOException($"Cannot write at position {_position}: stream has ended (size: {_size}). The target partition may be smaller than the source.");
|
||||
|
||||
Buffer.BlockCopy(buffer, offset, _buffer, (int)_position, count);
|
||||
_position += count;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
namespace Duplicati.Proprietary.DiskImage.Partition;
|
||||
namespace Duplicati.Proprietary.DiskImage.General;
|
||||
|
||||
/// <summary>
|
||||
/// Provides CRC32 calculation utilities for partition table operations.
|
||||
@@ -28,6 +28,11 @@ internal static class OptionsHelper
|
||||
/// </summary>
|
||||
internal const string DISK_RESTORE_VALIDATE_SIZE_OPTION = "diskimage-restore-validate-size";
|
||||
|
||||
/// <summary>
|
||||
/// Option to treat filesystem as unknown (force raw block-based backup).
|
||||
/// </summary>
|
||||
internal const string DISK_IMAGE_FILESYSTEM_UNKNOWN_OPTION = "diskimage-filesystem-unknown";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of supported command-line arguments for the DiskImage module.
|
||||
/// </summary>
|
||||
@@ -35,6 +40,7 @@ internal static class OptionsHelper
|
||||
[
|
||||
new CommandLineArgument(DISK_RESTORE_AUTO_UNMOUNT_OPTION, CommandLineArgument.ArgumentType.Boolean, Strings.DiskRestoreAutoUnmountShort, Strings.DiskRestoreAutoUnmountLong, "false"),
|
||||
new CommandLineArgument(DISK_RESTORE_SKIP_PARTITION_TABLE_OPTION, CommandLineArgument.ArgumentType.Boolean, Strings.DiskRestoreSkipPartitionTableShort, Strings.DiskRestoreSkipPartitionTableLong, "false"),
|
||||
new CommandLineArgument(DISK_RESTORE_VALIDATE_SIZE_OPTION, CommandLineArgument.ArgumentType.Boolean, Strings.DiskRestoreValidateSizeShort, Strings.DiskRestoreValidateSizeLong, "true")
|
||||
new CommandLineArgument(DISK_RESTORE_VALIDATE_SIZE_OPTION, CommandLineArgument.ArgumentType.Boolean, Strings.DiskRestoreValidateSizeShort, Strings.DiskRestoreValidateSizeLong, "true"),
|
||||
new CommandLineArgument(DISK_IMAGE_FILESYSTEM_UNKNOWN_OPTION, CommandLineArgument.ArgumentType.Boolean, Strings.DiskImageFilesystemUnknownShort, Strings.DiskImageFilesystemUnknownLong, "false")
|
||||
];
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
|
||||
namespace Duplicati.Proprietary.DiskImage.Disk;
|
||||
namespace Duplicati.Proprietary.DiskImage.General;
|
||||
|
||||
/// <summary>
|
||||
/// A disposable struct that rents a byte array from the shared ArrayPool and returns it when disposed.
|
||||
+1
-1
@@ -2,7 +2,7 @@ using System;
|
||||
using System.Buffers;
|
||||
using System.IO;
|
||||
|
||||
namespace Duplicati.Proprietary.DiskImage.Disk;
|
||||
namespace Duplicati.Proprietary.DiskImage.General;
|
||||
|
||||
/// <summary>
|
||||
/// A read-only stream that wraps a pooled byte array and returns it to the pool when disposed.
|
||||
@@ -130,4 +130,14 @@ internal static class Strings
|
||||
/// Long description for the web module path option.
|
||||
/// </summary>
|
||||
public static string WebModulePathLong => LC.L("The path to use for the web module operation.");
|
||||
|
||||
/// <summary>
|
||||
/// Short description for the filesystem unknown option.
|
||||
/// </summary>
|
||||
public static string DiskImageFilesystemUnknownShort => LC.L("Treat filesystem as unknown.");
|
||||
|
||||
/// <summary>
|
||||
/// Long description for the filesystem unknown option.
|
||||
/// </summary>
|
||||
public static string DiskImageFilesystemUnknownLong => LC.L("If set, the filesystem on partitions will be treated as unknown, rather than trying to detect it. This forces raw block-based backup instead of filesystem-aware backup.");
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Library.Utility;
|
||||
using Duplicati.Proprietary.DiskImage.Disk;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
|
||||
namespace Duplicati.Proprietary.DiskImage.Partition;
|
||||
@@ -391,7 +393,7 @@ internal class GPT : IPartitionTable
|
||||
PartitionType partitionType = DeterminePartitionType(typeGuid);
|
||||
|
||||
// Determine filesystem type based on partition name and known patterns
|
||||
FileSystemType fsType = DetermineFilesystemType(name, typeGuid);
|
||||
FileSystemType fsType = DetermineFilesystemType(startOffset, (int)size, typeGuid.ToString(), CancellationToken.None);
|
||||
|
||||
return new BasePartition
|
||||
{
|
||||
@@ -421,40 +423,27 @@ internal class GPT : IPartitionTable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the filesystem type based on the partition name and type GUID.
|
||||
/// Uses heuristics based on common naming patterns and known GUIDs.
|
||||
/// Determines the filesystem type based on the partition type GUID and partition data.
|
||||
/// </summary>
|
||||
/// <param name="name">The partition name.</param>
|
||||
/// <param name="typeGuid">The partition type GUID.</param>
|
||||
/// <returns>The corresponding <see cref="FileSystemType"/>.</returns>
|
||||
private static FileSystemType DetermineFilesystemType(string name, Guid typeGuid)
|
||||
private FileSystemType DetermineFilesystemType(long offset, int size, string typeGuid, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
// First, we try to look at the GUID
|
||||
if (typeGuid == "48465300-0000-11AA-AA11-00306543ECAC")
|
||||
return FileSystemType.HFSPlus;
|
||||
|
||||
if (typeGuid == "7C3457EF-0000-11AA-AA11-00306543ECAC")
|
||||
return FileSystemType.APFS;
|
||||
|
||||
if (m_rawDisk is not null)
|
||||
{
|
||||
var upperName = name.ToUpperInvariant();
|
||||
if (upperName.Contains("NTFS")) return FileSystemType.NTFS;
|
||||
if (upperName.Contains("FAT32")) return FileSystemType.FAT32;
|
||||
if (upperName.Contains("FAT16")) return FileSystemType.FAT16;
|
||||
if (upperName.Contains("FAT12")) return FileSystemType.FAT12;
|
||||
if (upperName.Contains("EXFAT")) return FileSystemType.ExFAT;
|
||||
if (upperName.Contains("HFS")) return FileSystemType.HFSPlus;
|
||||
if (upperName.Contains("APFS")) return FileSystemType.APFS;
|
||||
if (upperName.Contains("EXT4")) return FileSystemType.Ext4;
|
||||
if (upperName.Contains("EXT3")) return FileSystemType.Ext3;
|
||||
if (upperName.Contains("EXT2")) return FileSystemType.Ext2;
|
||||
if (upperName.Contains("XFS")) return FileSystemType.XFS;
|
||||
if (upperName.Contains("BTRFS")) return FileSystemType.Btrfs;
|
||||
if (upperName.Contains("ZFS")) return FileSystemType.ZFS;
|
||||
if (upperName.Contains("REFS")) return FileSystemType.ReFS;
|
||||
if (Fat32Filesystem.DetectAsync(m_rawDisk, offset, size, cancellationToken).Await())
|
||||
return FileSystemType.FAT32;
|
||||
}
|
||||
|
||||
// Fallback based on GUID
|
||||
return typeGuid.ToString().ToUpper() switch
|
||||
{
|
||||
"48465300-0000-11AA-AA11-00306543ECAC" => FileSystemType.HFSPlus,
|
||||
"7C3457EF-0000-11AA-AA11-00306543ECAC" => FileSystemType.APFS,
|
||||
_ => FileSystemType.Unknown
|
||||
};
|
||||
return FileSystemType.Unknown;
|
||||
}
|
||||
|
||||
// GPT-specific properties
|
||||
|
||||
@@ -4,7 +4,9 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Library.Utility;
|
||||
using Duplicati.Proprietary.DiskImage.Disk;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
|
||||
namespace Duplicati.Proprietary.DiskImage.Partition;
|
||||
@@ -135,7 +137,7 @@ internal class MBR : IPartitionTable
|
||||
StartOffset = entry.StartLBA * m_bytesPerSector,
|
||||
Size = entry.SizeInSectors * m_bytesPerSector,
|
||||
Name = $"Partition {i + 1}",
|
||||
FilesystemType = DetermineFilesystemType(entry),
|
||||
FilesystemType = entry.FilesystemType,
|
||||
VolumeGuid = null,
|
||||
RawDisk = m_rawDisk,
|
||||
StartingLba = entry.StartLBA,
|
||||
@@ -185,6 +187,7 @@ internal class MBR : IPartitionTable
|
||||
{
|
||||
EntryNumber = entryNumber,
|
||||
PartitionType = DeterminePartitionType(partitionType),
|
||||
FilesystemType = DetermineFilesystemType(partitionType, startLBA * m_bytesPerSector, (int)(sizeInSectors * m_bytesPerSector)),
|
||||
PartitionTypeByte = partitionType,
|
||||
StartLBA = startLBA,
|
||||
SizeInSectors = sizeInSectors,
|
||||
@@ -210,11 +213,21 @@ internal class MBR : IPartitionTable
|
||||
/// <summary>
|
||||
/// Determines the filesystem type based on the partition type byte.
|
||||
/// </summary>
|
||||
/// <param name="entry">The MBR partition entry.</param>
|
||||
/// <param name="typeByte">The partition type byte from the MBR.</param>
|
||||
/// <param name="offset">The offset of the partition within the disk.</param>
|
||||
/// <param name="size">The size of the partition in bytes.</param>
|
||||
/// <returns>The determined filesystem type.</returns>
|
||||
private static FileSystemType DetermineFilesystemType(MBRPartitionEntry entry)
|
||||
private FileSystemType DetermineFilesystemType(byte typeByte, long offset, int size)
|
||||
{
|
||||
return MbrPartitionTypes.ToFilesystemType(entry.PartitionTypeByte);
|
||||
var typeByByte = MbrPartitionTypes.ToFilesystemType(typeByte);
|
||||
if (typeByByte is FileSystemType.Unknown && m_rawDisk != null)
|
||||
{
|
||||
// If the partition type byte is unknown, attempt to detect the filesystem by reading the partition data
|
||||
if (Fat32Filesystem.DetectAsync(m_rawDisk, offset, size, CancellationToken.None).Await())
|
||||
return FileSystemType.FAT32;
|
||||
}
|
||||
|
||||
return typeByByte;
|
||||
}
|
||||
|
||||
// MBR-specific properties
|
||||
@@ -318,6 +331,9 @@ internal class MBR : IPartitionTable
|
||||
/// <summary>Gets the partition type.</summary>
|
||||
public PartitionType PartitionType { get; init; }
|
||||
|
||||
/// <summary>Gets the filesystem type.</summary>
|
||||
public FileSystemType FilesystemType { get; init; }
|
||||
|
||||
/// <summary>Gets the raw partition type byte.</summary>
|
||||
public byte PartitionTypeByte { get; init; }
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ using Duplicati.Library.Logging;
|
||||
using Duplicati.Library.Utility;
|
||||
using Duplicati.Proprietary.DiskImage.Disk;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
using Duplicati.Proprietary.DiskImage.Partition;
|
||||
|
||||
@@ -577,24 +578,19 @@ public sealed class RestoreProvider : IRestoreDestinationProviderModule, IDispos
|
||||
/// <summary>
|
||||
/// Checks if a file path is the geometry metadata file.
|
||||
/// </summary>
|
||||
/// <param name="path">The file path to check.</param>
|
||||
/// <remarks>Assumes the path has already been normalized by ParsePath.</remarks>
|
||||
/// <param name="path">The file path to check (already normalized by ParsePath).</param>
|
||||
/// <returns><c>true</c> if the path is the geometry file; otherwise, <c>false</c>.</returns>
|
||||
private static bool IsGeometryFile(string path)
|
||||
private bool IsGeometryFile(string path)
|
||||
{
|
||||
// Check for geometry.json (must be at root or top level)
|
||||
// Valid paths: "geometry.json", "root/geometry.json"
|
||||
if (!path.EndsWith("geometry.json", StringComparison.OrdinalIgnoreCase))
|
||||
if (_targetDisk == null)
|
||||
return false;
|
||||
|
||||
// TODO only check the last path for now.
|
||||
return true;
|
||||
string geometryDevicePath = NormalizePath(_targetDisk.DevicePath);
|
||||
|
||||
// Normalize path separators
|
||||
var normalized = path.Replace('\\', '/').TrimStart('/');
|
||||
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
string expectedGeometryPath = $"{geometryDevicePath}{Path.DirectorySeparatorChar}geometry.json";
|
||||
|
||||
// Should be at most 2 segments (e.g. "root/geometry.json")
|
||||
return segments.Length <= 2;
|
||||
return string.Equals(path, expectedGeometryPath, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -777,12 +773,21 @@ public sealed class RestoreProvider : IRestoreDestinationProviderModule, IDispos
|
||||
/// <returns>An IFilesystem instance, or null if the filesystem type is not supported.</returns>
|
||||
private IFilesystem? CreateFilesystemFromGeometry(IPartition partition, FilesystemGeometry fsGeom)
|
||||
{
|
||||
return fsGeom.Type switch
|
||||
try
|
||||
{
|
||||
// For now, we use UnknownFilesystem as the base implementation
|
||||
// Specific filesystem implementations can be added later
|
||||
_ => new UnknownFilesystem(partition, fsGeom.BlockSize)
|
||||
};
|
||||
return fsGeom.Type switch
|
||||
{
|
||||
FileSystemType.FAT32 => new Fat32Filesystem(partition, fsGeom.BlockSize),
|
||||
_ => new UnknownFilesystem(partition, fsGeom.BlockSize)
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If creating the filesystem fails (e.g., invalid boot sector on blank disk),
|
||||
// fall back to UnknownFilesystem for raw block access but report the original type
|
||||
// so that path lookup works correctly
|
||||
return new UnknownFilesystem(partition, fsGeom.BlockSize, fsGeom.Type);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -102,7 +102,7 @@ internal class DiskSourceEntry(SourceProvider provider, IRawDisk disk)
|
||||
});
|
||||
|
||||
// Create partition source entry which will add filesystem geometry
|
||||
var partitionEntry = new PartitionSourceEntry(this.Path, partition);
|
||||
var partitionEntry = new PartitionSourceEntry(this.Path, partition, provider.TreatFilesystemAsUnknown);
|
||||
|
||||
// Pre-enumerate to collect filesystem geometry
|
||||
await foreach (var child in partitionEntry.Enumerate(cancellationToken))
|
||||
@@ -124,7 +124,7 @@ internal class DiskSourceEntry(SourceProvider provider, IRawDisk disk)
|
||||
|
||||
// Then yield the actual partition and filesystem entries
|
||||
foreach (var partition in partitions)
|
||||
yield return new PartitionSourceEntry(this.Path, partition);
|
||||
yield return new PartitionSourceEntry(this.Path, partition, provider.TreatFilesystemAsUnknown);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace Duplicati.Proprietary.DiskImage.SourceItems;
|
||||
/// Represents a file within a filesystem as a source entry for backup operations.
|
||||
/// </summary>
|
||||
internal class FileSourceEntry(string parentPath, IFilesystem filesystem, IFile file)
|
||||
: DiskImageEntryBase(System.IO.Path.Combine(parentPath, file.Path ?? file.Address?.ToString("X016") ?? "unknown"))
|
||||
: DiskImageEntryBase(System.IO.Path.Combine(parentPath, file.Address?.ToString("X016") ?? file.Path ?? "unknown"))
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override bool IsFolder => file.IsDirectory;
|
||||
@@ -23,6 +23,10 @@ internal class FileSourceEntry(string parentPath, IFilesystem filesystem, IFile
|
||||
/// <inheritdoc />
|
||||
public override long Size => file.Size;
|
||||
|
||||
/// If the file doesn't report a last modified time, use the current time as a fallback to always back it up.
|
||||
/// <inheritdoc />
|
||||
public override DateTime LastModificationUtc => file.LastModified ?? DateTime.UtcNow;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<Stream> OpenRead(CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Library.Interface;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
|
||||
namespace Duplicati.Proprietary.DiskImage.SourceItems;
|
||||
@@ -52,6 +53,10 @@ internal class FilesystemSourceEntry(string parentPath, IFilesystem filesystem)
|
||||
{
|
||||
blockSize = unknownMeta.BlockSize;
|
||||
}
|
||||
else if (fsMetadata is Fat32FilesystemMetadata fat32Meta)
|
||||
{
|
||||
blockSize = fat32Meta.BlockSize;
|
||||
}
|
||||
|
||||
return new FilesystemGeometry
|
||||
{
|
||||
@@ -87,6 +92,15 @@ internal class FilesystemSourceEntry(string parentPath, IFilesystem filesystem)
|
||||
{
|
||||
metadata["filesystem:BlockSize"] = unknownMeta.BlockSize.ToString();
|
||||
}
|
||||
// For FAT32, extract cluster information
|
||||
else if (fsMetadata is Fat32FilesystemMetadata fat32Meta)
|
||||
{
|
||||
metadata["filesystem:BlockSize"] = fat32Meta.BlockSize.ToString();
|
||||
metadata["filesystem:ClusterSize"] = fat32Meta.ClusterSize.ToString();
|
||||
metadata["filesystem:TotalClusters"] = fat32Meta.TotalClusters.ToString();
|
||||
metadata["filesystem:AllocatedClusters"] = fat32Meta.AllocatedClusters.ToString();
|
||||
metadata["filesystem:FreeClusters"] = fat32Meta.FreeClusters.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -26,9 +26,15 @@ internal class GeometrySourceEntry : DiskImageEntryBase
|
||||
_metadata = metadata;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool IsFolder => false;
|
||||
/// <inheritdoc />
|
||||
public override bool IsMetaEntry => false;
|
||||
/// <inheritdoc />
|
||||
public override long Size => Encoding.UTF8.GetByteCount(_metadata.ToJson());
|
||||
/// Geometry should always be backed up, as this captures re-partitionings.
|
||||
/// <inheritdoc />
|
||||
public override DateTime LastModificationUtc => DateTime.UtcNow;
|
||||
|
||||
public GeometryMetadata Metadata => _metadata;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ using Duplicati.Proprietary.DiskImage.Partition;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem;
|
||||
using Duplicati.Library.Utility;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
|
||||
|
||||
namespace Duplicati.Proprietary.DiskImage.SourceItems;
|
||||
|
||||
@@ -18,7 +19,7 @@ namespace Duplicati.Proprietary.DiskImage.SourceItems;
|
||||
/// Represents a partition as a source entry for backup operations.
|
||||
/// Acts as a container for the filesystem within the partition.
|
||||
/// </summary>
|
||||
internal class PartitionSourceEntry(string parentPath, IPartition partition)
|
||||
internal class PartitionSourceEntry(string parentPath, IPartition partition, bool treatFilesystemAsUnknown = false)
|
||||
: DiskImageEntryBase(System.IO.Path.Combine(parentPath, $"part_{partition.PartitionTable.TableType}_{partition.PartitionNumber}{System.IO.Path.DirectorySeparatorChar}"))
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@@ -32,15 +33,21 @@ internal class PartitionSourceEntry(string parentPath, IPartition partition)
|
||||
/// </summary>
|
||||
public IPartition Partition => partition;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether to treat the filesystem as unknown.
|
||||
/// </summary>
|
||||
private bool TreatFilesystemAsUnknown => treatFilesystemAsUnknown;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async IAsyncEnumerable<ISourceProviderEntry> Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var fs = partition.FilesystemType switch
|
||||
{
|
||||
|
||||
FileSystemType.Unknown => new UnknownFilesystem(partition),
|
||||
_ => new UnknownFilesystem(partition)
|
||||
};
|
||||
IFilesystem fs = TreatFilesystemAsUnknown || partition.FilesystemType == FileSystemType.Unknown
|
||||
? new UnknownFilesystem(partition)
|
||||
: partition.FilesystemType switch
|
||||
{
|
||||
FileSystemType.FAT32 => new Fat32Filesystem(partition),
|
||||
_ => new UnknownFilesystem(partition)
|
||||
};
|
||||
yield return new FilesystemSourceEntry(this.Path, fs);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,11 @@ public sealed class SourceProvider : ISourceProviderModule, IDisposable
|
||||
/// </remarks>
|
||||
private readonly ConcurrentDictionary<string, ISourceProviderEntry> _entryCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether to treat filesystems as unknown (force raw block-based backup).
|
||||
/// </summary>
|
||||
private readonly bool _treatFilesystemAsUnknown;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SourceProvider"/> class.
|
||||
/// Default constructor for metadata loading.
|
||||
@@ -56,6 +61,7 @@ public sealed class SourceProvider : ISourceProviderModule, IDisposable
|
||||
public SourceProvider()
|
||||
{
|
||||
_devicePath = null!;
|
||||
_treatFilesystemAsUnknown = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -71,6 +77,8 @@ public sealed class SourceProvider : ISourceProviderModule, IDisposable
|
||||
|
||||
var uri = new Library.Utility.Uri(url);
|
||||
_devicePath = uri.HostAndPath;
|
||||
|
||||
_treatFilesystemAsUnknown = Library.Utility.Utility.ParseBoolOption(options, OptionsHelper.DISK_IMAGE_FILESYSTEM_UNKNOWN_OPTION);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -88,6 +96,11 @@ public sealed class SourceProvider : ISourceProviderModule, IDisposable
|
||||
/// <inheritdoc />
|
||||
public IList<ICommandLineArgument> SupportedCommands => OptionsHelper.SupportedCommands;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether to treat filesystems as unknown (force raw block-based backup).
|
||||
/// </summary>
|
||||
internal bool TreatFilesystemAsUnknown => _treatFilesystemAsUnknown;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task Initialize(CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user