Files
duplicati/Duplicati/Library/Main/Backend/BackendManager.GetOperation.cs
T
JamBalaya56562andClaude Opus 4.8 1470b05a4b Address PR review: async parity API, opt-in module, per-operation instance, tests
Responds to the review on #7020:
- IParity Create/Verify/Repair are now async (CancellationToken); the par2
  runner uses WaitForExitAsync and awaits the output reads directly.
- Removed ParityBase; Par2Parity implements IParity directly.
- parity-redundancy-level is parsed via Utility.ParseIntOption and validated as
  positive, and moved into the par2 module's own options. no-parity removed;
  parity-module now defaults to empty (opt-in) and is the sole toggle.
- The availability probe and "not found" warning are per-instance; BackendManager
  holds one shared parity instance per operation (SharedParityProvider), disposed
  with the manager, instead of constructing one per volume.
- The download repair path guards on Blocks/Files volume type; a successful parity
  repair now logs a warning; the test/verification flow disables repair
  (allowParityRepair=false) so damage is reported rather than masked.
- Added a full backup -> corrupt -> detect -> restore/recover integration test.
- Fix: par2cmdline 0.8.1 aborts on single-character protected filenames, so the
  working-copy base name is now "data" instead of "d". Verified end-to-end against
  real par2 in a Linux container (all parity tests pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 00:57:54 +09:00

367 lines
18 KiB
C#

using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Duplicati.Library.Interface;
using Duplicati.Library.Utility;
using Duplicati.StreamUtil;
using SharpAESCrypt;
namespace Duplicati.Library.Main.Backend;
#nullable enable
partial class BackendManager
{
/// <summary>
/// Represents a pending GET operation
/// </summary>
private class GetOperation : PendingOperation<(TempFile File, string Hash, long Size)>
{
/// <summary>
/// The log tag for this class
/// </summary>
private static readonly string LOGTAG = Logging.Log.LogTagFromType<GetOperation>();
/// <summary>
/// The remote filename that is to be downloaded
/// </summary>
public override string RemoteFilename { get; }
/// <summary>
/// The size of the remote file, or -1 if unknown
/// </summary>
public override long Size { get; }
/// <summary>
/// Flag indicating whether the file should be decrypted
/// </summary>
public bool Decrypt { get; set; }
/// <summary>
/// The hash of the remote file, or null if unknown
/// </summary>
public required string? Hash { get; set; }
/// <summary>
/// Whether a failed download may be repaired in place using parity data. This is
/// enabled for normal operations, but disabled for the test/verification flow so
/// that damaged files are reported rather than silently repaired.
/// </summary>
public bool AllowParityRepair { get; set; } = true;
/// <summary>
/// The operation type
/// </summary>
public override BackendActionType Operation => BackendActionType.Get;
/// <summary>
/// Creates a new GetOperation
/// </summary>
/// <param name="remotefilename">The remote filename to download</param>
/// <param name="size">The size of the remote file, or -1 if unknown</param>
/// <param name="context">The execution context</param>
/// <param name="cancelToken">The cancellation token</param>
public GetOperation(string remotefilename, long size, ExecuteContext context, CancellationToken cancelToken)
: base(context, true, cancelToken)
{
RemoteFilename = remotefilename;
Size = size;
}
/// <summary>
/// Executes the operation
/// </summary>
/// <param name="backend">The backend to download from</param>
/// <param name="cancelToken">The cancellation token</param>
/// <returns>The file, hash and size of the downloaded file</returns>
public override async Task<(TempFile File, string Hash, long Size)> ExecuteAsync(IBackend backend, CancellationToken cancelToken)
{
TempFile? tmpfile = null;
try
{
Context.Statwriter.SendEvent(BackendActionType.Get, BackendEventType.Started, RemoteFilename, Size);
// Start and time the donwload
var begin = DateTime.Now;
(tmpfile, var dataSizeDownloaded, var fileHash) = await DoGetFileAsync(backend, cancelToken).ConfigureAwait(false);
var duration = DateTime.Now - begin;
Logging.Log.WriteProfilingMessage(LOGTAG, "DownloadSpeed", "Downloaded {0} to {1} ({2}) in {3}, {4}/s",
RemoteFilename, tmpfile.Name, Library.Utility.Utility.FormatSizeString(dataSizeDownloaded),
duration, Library.Utility.Utility.FormatSizeString((long)(dataSizeDownloaded / duration.TotalSeconds)));
Context.Database.LogRemoteOperation("get", RemoteFilename, System.Text.Json.JsonSerializer.Serialize(new { Size = dataSizeDownloaded, Hash = fileHash }));
Context.Statwriter.SendEvent(BackendActionType.Get, BackendEventType.Completed, RemoteFilename, dataSizeDownloaded);
if (!Context.Options.SkipFileHashChecks)
{
var sizeMismatch = Size >= 0 && dataSizeDownloaded != Size;
var hashMismatch = !string.IsNullOrEmpty(Hash) && fileHash != Hash;
if (sizeMismatch || hashMismatch)
{
// The downloaded file failed verification; attempt a best-effort
// repair using a parity companion file, if one is available. In the
// test/verification flow (AllowParityRepair == false) we skip the
// repair so that damaged files are reported instead of masked.
if (AllowParityRepair && await TryRepairWithParityAsync(backend, tmpfile, cancelToken).ConfigureAwait(false))
{
dataSizeDownloaded = new System.IO.FileInfo(tmpfile).Length;
fileHash = CalculateFileHash(tmpfile, Context.Options);
sizeMismatch = Size >= 0 && dataSizeDownloaded != Size;
hashMismatch = !string.IsNullOrEmpty(Hash) && fileHash != Hash;
}
if (sizeMismatch)
throw new Exception(Strings.Controller.DownloadedFileSizeError(RemoteFilename, dataSizeDownloaded, Size));
if (hashMismatch)
throw new HashMismatchException(Strings.Controller.HashMismatchError(RemoteFilename, Hash, fileHash));
}
}
// Perform decryption after hash validation, if needed
if (Decrypt)
tmpfile = DecryptFile(tmpfile, RemoteFilename, Context.Options, true);
return (tmpfile, fileHash, dataSizeDownloaded);
}
catch
{
tmpfile?.Dispose();
throw;
}
finally
{
Context.ProgressHandler.EndTransfer(BackendActionType.Get, RemoteFilename);
}
}
/// <summary>
/// Attempts to repair a downloaded file that failed verification, using a
/// parity companion file downloaded from the same backend. This is best-effort:
/// if parity is disabled, the parity file is missing, or the engine is
/// unavailable, it returns false and the caller reports the original error.
/// </summary>
/// <param name="backend">The backend to download the parity file from</param>
/// <param name="tmpfile">The downloaded (still-encrypted) file to repair in place</param>
/// <param name="cancelToken">The cancellation token</param>
/// <returns>True if the file was repaired and is now intact</returns>
private async Task<bool> TryRepairWithParityAsync(IBackend backend, TempFile tmpfile, CancellationToken cancelToken)
{
var parityModule = Context.Options.ParityModule;
if (string.IsNullOrEmpty(parityModule))
return false;
// Do not attempt to repair a parity file itself, or non-volume files. Only
// data volumes (dblock/dlist) have parity companions; for anything else there
// is no parity file to find.
var parsed = Volumes.VolumeBase.ParseFilename(RemoteFilename);
if (parsed == null || parsed.IsParity)
return false;
if (parsed.FileType != RemoteVolumeType.Blocks && parsed.FileType != RemoteVolumeType.Files)
return false;
try
{
// The shared parity instance lives for the whole operation, so the
// availability probe and any "not found" warning happen once, not per volume.
var parity = Context.Parity.Get();
if (parity == null || !parity.IsAvailable)
return false;
var parityEffectiveName = GetEffectiveRemoteName() + "." + parity.FilenameExtension;
using var parityTemp = new TempFile();
try
{
await backend.GetAsync(parityEffectiveName, parityTemp, cancelToken).ConfigureAwait(false);
}
catch (Exception ex)
{
Logging.Log.WriteVerboseMessage(LOGTAG, "ParityFileNotAvailable", ex, "No parity file available to repair {0}: {1}", RemoteFilename, ex.Message);
return false;
}
if (await parity.RepairAsync(tmpfile, parityTemp, cancelToken).ConfigureAwait(false))
{
// Repairing a volume means the remote data was damaged; emit a warning
// so it is visible that the backup data is no longer in prime condition.
Logging.Log.WriteWarningMessage(LOGTAG, "ParityRepairSuccess", null, "Repaired {0} using parity data; the remote copy is damaged and should be checked", RemoteFilename);
return true;
}
Logging.Log.WriteWarningMessage(LOGTAG, "ParityRepairFailed", null, "Parity repair did not succeed for {0}", RemoteFilename);
return false;
}
catch (Exception ex)
{
Logging.Log.WriteWarningMessage(LOGTAG, "ParityRepairError", ex, "Error during parity repair of {0}: {1}", RemoteFilename, ex.Message);
return false;
}
}
/// <summary>
/// Downloads a file from the backend
/// </summary>
/// <param name="backend">The backend to download from</param>
/// <param name="cancelToken">The cancellation token</param>
/// <returns>The downloaded file, the size of the file, and the hash of the file</returns>
private async Task<(TempFile tempFile, long downloadSize, string remotehash)> DoGetFileAsync(IBackend backend, CancellationToken cancelToken)
{
TempFile? dlTarget = null;
try
{
long retDownloadSize;
string retHashcode;
dlTarget = new TempFile();
Context.ProgressHandler.BeginTransfer(BackendActionType.Get, Size, RemoteFilename);
// The effective remote name is the name passed to the backend. For
// folder-enabled backends it equals RemoteFilename (the full relative
// path); for non-folder backends the backend is pointed at the file's
// sub-folder (via BackendUrlOverride) and this is just the filename.
var effectiveName = GetEffectiveRemoteName();
if (backend is IStreamingBackend streamingBackend && streamingBackend.SupportsStreaming && !Context.Options.DisableStreamingTransfers)
{
// extended to use stacked streams
using (var fs = System.IO.File.OpenWrite(dlTarget))
using (var hasher = HashFactory.CreateHasher(Context.Options.FileHashAlgorithm))
using (var hs = new HashCalculatingStream(fs, hasher))
using (var ss = new ShaderStream(hs, true))
{
using (var ts = new ThrottleEnabledStream(ss, Context.UploadThrottleManager, Context.DownloadThrottleManager))
using (var pgs = new ProgressReportingStream(ts, pg => Context.ProgressHandler.HandleProgress(pg, RemoteFilename)))
{
await streamingBackend.GetAsync(effectiveName, pgs, cancelToken).ConfigureAwait(false);
}
await ss.FlushAsync();
retDownloadSize = ss.TotalBytesWritten;
retHashcode = Convert.ToBase64String(hs.GetFinalHash());
}
}
else
{
await backend.GetAsync(effectiveName, dlTarget, cancelToken).ConfigureAwait(false);
retDownloadSize = new System.IO.FileInfo(dlTarget).Length;
retHashcode = CalculateFileHash(dlTarget, Context.Options);
}
var retTarget = dlTarget;
dlTarget = null;
return (retTarget, retDownloadSize, retHashcode);
}
finally
{
// Remove temp files on failure
dlTarget?.Dispose();
}
}
/// <summary>
/// Detects the encryption module to use, based on the filename.
/// This makes it possible to restore from a folder with mixed encryption modules.
/// </summary>
/// <param name="encryption">The default encryption module</param>
/// <returns>The encryption module to use</returns>
private static IEncryption? DetectEncryptionModule(string filename, Options options, IEncryption? encryption)
{
try
{
// Auto-guess the encryption module
var ext = (System.IO.Path.GetExtension(filename) ?? "").TrimStart('.');
if (!ext.Equals(encryption?.FilenameExtension, StringComparison.OrdinalIgnoreCase))
{
// Check if the file is not encrypted
if (DynamicLoader.CompressionLoader.Keys.Contains(ext, StringComparer.OrdinalIgnoreCase))
{
if (encryption != null)
Logging.Log.WriteVerboseMessage(LOGTAG, "AutomaticDecryptionDetection", "Filename extension \"{0}\" does not match encryption module \"{1}\", guessing that it is not encrypted", ext, options.EncryptionModule);
return null;
}
// Check if the file is encrypted with something else
else if (DynamicLoader.EncryptionLoader.Keys.Contains(ext, StringComparer.OrdinalIgnoreCase))
{
Logging.Log.WriteVerboseMessage(LOGTAG, "AutomaticDecryptionDetection", "Filename extension \"{0}\" does not match encryption module \"{1}\", attempting to use matching encryption module", ext, options.EncryptionModule);
try
{
return DynamicLoader.EncryptionLoader.GetModule(ext, options.Passphrase, options.RawOptions)
?? encryption;
}
catch (Exception ex)
{
Logging.Log.WriteWarningMessage(LOGTAG, "AutomaticDecryptionDetection", ex, "Failed to load encryption module \"{0}\", using specified encryption module \"{1}\"", ext, options.EncryptionModule);
}
}
// Fallback, lets see what happens...
else
{
Logging.Log.WriteVerboseMessage(LOGTAG, "AutomaticDecryptionDetection", "Filename extension \"{0}\" does not match encryption module \"{1}\", attempting to use specified encryption module as no others match", ext, options.EncryptionModule);
}
}
return encryption;
}
// If we fail here, make sure that we throw a crypto exception
catch (System.Security.Cryptography.CryptographicException) { throw; }
catch (Exception ex) { throw new System.Security.Cryptography.CryptographicException(ex.Message, ex); }
}
/// <summary>
/// Performs decryption of a file. This could be more efficient if we decrypt while downloading,
/// but that would prevent us from verifying the hash of the encrypted file, before decrypting it.
/// Decrypting afterwards also ensures we can control the thrown exceptions.
/// </summary>
/// <param name="tempFile">The encrypted file</param>
/// <param name="decrypter">Then encryption module to use, or <c>null</c> for no encryption</param>
/// <returns>The decrypted file</returns>
private static TempFile DecryptFile(TempFile tempFile, IEncryption? decrypter, bool dispose)
{
// Support no encryption
if (decrypter == null)
return tempFile;
TempFile? decryptTarget = null;
// Always dispose the source file
using (dispose ? tempFile : null)
using (new Logging.Timer(LOGTAG, "DecryptFile", "Decrypting " + tempFile))
{
try
{
decryptTarget = new TempFile();
try { decrypter.Decrypt(tempFile, decryptTarget); }
// If we fail here, make sure that we throw a crypto exception
catch (System.Security.Cryptography.CryptographicException) { throw; }
catch (Exception ex) { throw new System.Security.Cryptography.CryptographicException(ex.Message, ex); }
var result = decryptTarget;
decryptTarget = null;
return result;
}
finally
{
// Remove temp files on failure
decryptTarget?.Dispose();
}
}
}
/// <summary>
/// Decrypts a file using the specified options
/// </summary>
/// <param name="tmpfile">The file to decrypt</param>
/// <param name="filename">The name of the file. Used for detecting encryption algorithm if not specified in options or if it differs from the options</param>
/// <param name="options">The Duplicati options</param>
/// <param name="dispose">If true, the source file will be disposed after decryption</param>
/// <returns>The decrypted file</returns>
public static TempFile DecryptFile(TempFile tmpfile, string filename, Options options, bool dispose)
{
using var encryption = options.NoEncryption
? null
: (DynamicLoader.EncryptionLoader.GetModule(options.EncryptionModule, options.Passphrase, options.RawOptions)
?? throw new Exception(Strings.BackendMananger.EncryptionModuleNotFound(options.EncryptionModule))
);
return DecryptFile(tmpfile, DetectEncryptionModule(filename, options, encryption), dispose);
}
}
}