// 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.Threading.Tasks; using CoCoL; using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; using Duplicati.Library.Main.Database; using Duplicati.Library.Utility; #nullable enable namespace Duplicati.Library.Main.Operation.Restore { /// /// Process that handles each file that needs to be restored. It starts by /// identifying the blocks that need to be restored. Then it verifies the /// which of the target file blocks are missing. Then it checks whether it /// can use local blocks to restore the file. Finally, it restores the file /// by downloading the missing blocks and writing them to the target file. /// It also verifies the file hash and truncates the file if necessary. /// If enabled, it also restores the metadata for the file. /// internal class FileProcessor { /// /// The log tag for this class. /// private static readonly string LOGTAG = Logging.Log.LogTagFromType(); /// /// The number of file processors that are still restoring files. It is set by the . /// public static int file_processors_restoring_files; public static object file_processor_continue_lock = new object(); public static TaskCompletionSource file_processor_continue = new(); /// /// Synchronization for priority files processing. /// FileProcessors wait until all priority files have been processed. /// public static int priority_files_remaining; public static object priority_files_lock = new object(); public static TaskCompletionSource priority_files_completed = new(); /// /// The current file processor ID. Used for debugging. /// private static int processor_id = -1; /// /// Runs the file processor process that restores the files that need to be restored. /// /// The named channels for the restore operation. /// The restore database, which is queried for blocks, corresponding volumes and metadata for the files. /// The channel to request blocks from the block manager. /// The channel to receive blocks from the block manager. /// The restore options. /// The restore results. public static Task Run(Channels channels, LocalRestoreDatabase db, IChannel block_request, IChannel> block_response, IRestoreDestinationProvider restoreDestination, Options options, RestoreResults results) { return AutomationExtensions.RunTask( new { Input = channels.FilesToRestore.AsRead() }, async self => { Stopwatch? sw_file = options.InternalProfiling ? new() : null; Stopwatch? sw_block = options.InternalProfiling ? new() : null; Stopwatch? sw_meta = options.InternalProfiling ? new() : null; Stopwatch? sw_req = options.InternalProfiling ? new() : null; Stopwatch? sw_resp = options.InternalProfiling ? new() : null; Stopwatch? sw_work = options.InternalProfiling ? new() : null; Stopwatch? sw_work_verify_target = options.InternalProfiling ? new() : null; Stopwatch? sw_work_retarget = options.InternalProfiling ? new() : null; Stopwatch? sw_work_notify_local = options.InternalProfiling ? new() : null; Stopwatch? sw_work_verify_local = options.InternalProfiling ? new() : null; Stopwatch? sw_work_empty_file = options.InternalProfiling ? new() : null; Stopwatch? sw_work_hash = options.InternalProfiling ? new() : null; Stopwatch? sw_work_read = options.InternalProfiling ? new() : null; Stopwatch? sw_work_write = options.InternalProfiling ? new() : null; Stopwatch? sw_work_results = options.InternalProfiling ? new() : null; Stopwatch? sw_work_meta = options.InternalProfiling ? new() : null; // Indicates whether this FileProcessor is still restoring files var decremented = false; // ID for this file processor, used for debugging. var my_id = Interlocked.Increment(ref processor_id); try { using var filehasher = HashFactory.CreateHasher(options.FileHashAlgorithm); using var blockhasher = HashFactory.CreateHasher(options.BlockHashAlgorithm); var buffer = new byte[options.Blocksize]; while (true) { // Get the next file to restore. sw_file?.Start(); var file = await self.Input.ReadAsync().ConfigureAwait(false); sw_file?.Stop(); // Check if this is a priority file and wait for all priority files to complete before processing non-priority files await WaitForPriorityFilesIfNeeded(file).ConfigureAwait(false); Logging.Log.WriteExplicitMessage(LOGTAG, "FileRestored", null, "{0} Restoring file {1}", my_id, file.TargetPath); if (file.BlocksetID == LocalDatabase.FOLDER_BLOCKSET_ID && !options.SkipMetadata) { // Check if there are other FileProcessor's still restoring files if (!decremented) { if (file_processors_restoring_files <= 0 && !file_processor_continue.Task.IsCompleted) file_processor_continue.SetResult(); else await RendezvousBeforeProcessingFolderMetadata() .ConfigureAwait(false); decremented = true; } await RestoreMetadata(db, file, restoreDestination, block_request, block_response, options, sw_meta, sw_work_meta, sw_req, sw_resp, results.TaskControl.ProgressToken) .ConfigureAwait(false); continue; } // Get information about the blocks for the file // TODO rather than keeping all of the blocks in memory, we could do a single pass over the blocks using a cursor, only keeping the relevant block requests in memory. Maybe even only a single block request at a time. sw_block?.Start(); var blocks = await db .GetBlocksFromFile(file.BlocksetID, results.TaskControl.ProgressToken) .ToArrayAsync(results.TaskControl.ProgressToken) .ConfigureAwait(false); sw_block?.Stop(); sw_work_verify_target?.Start(); // Verify the target file blocks that may already exist. long bytes_verified; List missing_blocks, verified_blocks; try { (bytes_verified, missing_blocks, verified_blocks) = await VerifyTargetBlocks(file, restoreDestination, blocks, filehasher, blockhasher, buffer, options, results, results.TaskControl.ProgressToken).ConfigureAwait(false); } catch (Exception ex) { Logging.Log.WriteErrorMessage(LOGTAG, "VerifyTargetBlocks", ex, "Error during checking the target file"); continue; } long bytes_written = 0; sw_work_verify_target?.Stop(); if (blocks.Length != missing_blocks.Count + verified_blocks.Count) { Logging.Log.WriteErrorMessage(LOGTAG, "BlockCountMismatch", null, $"Block count mismatch for {file.TargetPath} - expected: {blocks.Length}, actual: {missing_blocks.Count + verified_blocks.Count}"); continue; } sw_work_retarget?.Start(); // Check if the target file needs to be retargeted if (missing_blocks.Count > 0 && !options.Overwrite && await restoreDestination.FileExists(file.TargetPath, results.TaskControl.ProgressToken).ConfigureAwait(false)) { var new_name = await GenerateNewName(file, db, restoreDestination, filehasher, results.TaskControl.ProgressToken).ConfigureAwait(false); if (await restoreDestination.FileExists(new_name, results.TaskControl.ProgressToken).ConfigureAwait(false)) { // The file already exists, which it only does when it matches the target hash. So we can skip the file. Logging.Log.WriteInformationMessage(LOGTAG, "FileAlreadyExists", "File {0} already exists and matches the target hash as a copy: {1}", file.TargetPath, new_name); missing_blocks.Clear(); } else { Logging.Log.WriteVerboseMessage(LOGTAG, "RetargetingFile", "Retargeting file {0} to {1}", file.TargetPath, new_name); var new_file = new FileRequest(file.ID, file.OriginalPath, new_name, file.Hash, file.Length, file.BlocksetID); if (options.UseLocalBlocks) { if (options.Dryrun) { Logging.Log.WriteDryrunMessage(LOGTAG, "DryrunRestore", @$"Would have copied {verified_blocks.Count} blocks ({verified_blocks.Count * options.Blocksize} bytes) from ""{file.TargetPath}"" to ""{new_file.TargetPath}"""); } else { try { await CopyOldTargetBlocksToNewTarget(file, new_file, restoreDestination, buffer, verified_blocks, results.TaskControl.ProgressToken).ConfigureAwait(false); } catch (Exception ex) { Logging.Log.WriteErrorMessage(LOGTAG, "CopyOldTargetToNew", ex, "Error when trying to copy {0} to {1}", file, new_file); results.BrokenLocalFiles.Add(file.TargetPath); } } } else { verified_blocks.Clear(); missing_blocks = [.. blocks.Select(x => { x.RequestType = BlockRequestType.Download; return x; })]; } file = new_file; } } sw_work_retarget?.Stop(); sw_work_notify_local?.Start(); // Notify to the cache that we use local blocks. foreach (var block in verified_blocks) { sw_req?.Start(); await block_request.WriteAsync( new BlockRequest( block.BlockID, block.BlockOffset, block.BlockHash, block.BlockSize, block.VolumeID, BlockRequestType.CacheEvict ) ).ConfigureAwait(false); sw_req?.Stop(); } sw_work_notify_local?.Stop(); sw_work_verify_local?.Start(); if (missing_blocks.Count > 0 && options.UseLocalBlocks) { // Verify the local blocks at the original restore path that may be used to restore the file. (bytes_written, missing_blocks) = await VerifyLocalBlocks(file, restoreDestination, missing_blocks, blocks.Length, filehasher, blockhasher, buffer, options, results, block_request, results.TaskControl.ProgressToken).ConfigureAwait(false); } sw_work_verify_local?.Stop(); bool empty_file_or_symlink = false; if (file.BlocksetID != LocalDatabase.SYMLINK_BLOCKSET_ID && (blocks.Length == 0 || (blocks.Length == 1 && blocks[0].BlockSize == 0))) { sw_work_empty_file?.Start(); empty_file_or_symlink = true; if (options.Dryrun) { Logging.Log.WriteDryrunMessage(LOGTAG, "DryrunRestore", @$"Would have created empty file ""{file.TargetPath}"""); } else { var foldername = SystemIO.IO_OS.PathGetDirectoryName(file.TargetPath); try { if (await restoreDestination.CreateFolderIfNotExists(foldername, results.TaskControl.ProgressToken).ConfigureAwait(false)) Logging.Log.WriteWarningMessage(LOGTAG, "CreateMissingFolder", null, @$"Creating missing folder ""{foldername}"" for file ""{file.TargetPath}"""); } catch (Exception ex) { Logging.Log.WriteErrorMessage(LOGTAG, "CreateMissingFolder", ex, @$"Error when trying to create missing folder ""{foldername}"" for file ""{file.TargetPath}"""); } // Create an empty file, or truncate to 0 try { using var fs = await restoreDestination.OpenWrite(file.TargetPath, results.TaskControl.ProgressToken).ConfigureAwait(false); fs.SetLength(0); } catch (Exception ex) { Logging.Log.WriteErrorMessage(LOGTAG, "CreateEmptyFile", ex, "Error when creating empty file {0}", file.TargetPath); continue; } if (missing_blocks.Count != 0) { await block_request.WriteAsync( new BlockRequest( blocks[0].BlockID, blocks[0].BlockOffset, blocks[0].BlockHash, blocks[0].BlockSize, blocks[0].VolumeID, BlockRequestType.CacheEvict ) ).ConfigureAwait(false); } } sw_work_empty_file?.Stop(); } else if (missing_blocks.Count > 0) { if (missing_blocks.Any(x => x.VolumeID < 0)) { Logging.Log.WriteWarningMessage(LOGTAG, "NegativeVolumeID", null, $"{file.TargetPath} has a negative volume ID, skipping"); continue; } sw_work_hash?.Start(); filehasher.Initialize(); sw_work_hash?.Stop(); Stream? fs = null; try { // Open the target file if (options.Dryrun) { // If dryrun, open the file for read only to verify the file hash. if (await restoreDestination.FileExists(file.TargetPath, results.TaskControl.ProgressToken).ConfigureAwait(false)) { fs = await restoreDestination.OpenRead(file.TargetPath, results.TaskControl.ProgressToken).ConfigureAwait(false); } else { Logging.Log.WriteDryrunMessage(LOGTAG, "DryrunRestore", @$"Tried opening ""{file.TargetPath}"" for reading, but it doesn't exist."); } } else { var foldername = SystemIO.IO_OS.PathGetDirectoryName(file.TargetPath); try { if (await restoreDestination.CreateFolderIfNotExists(foldername, results.TaskControl.ProgressToken).ConfigureAwait(false)) Logging.Log.WriteWarningMessage(LOGTAG, "CreateMissingFolder", null, @$"Creating missing folder ""{foldername}"" for file ""{file.TargetPath}"""); } catch (Exception ex) { Logging.Log.WriteErrorMessage(LOGTAG, "CreateMissingFolder", ex, @$"Error when trying to create missing folder ""{foldername}"" for file ""{file.TargetPath}"""); } fs = await restoreDestination.OpenReadWrite(file.TargetPath, results.TaskControl.ProgressToken).ConfigureAwait(false); } sw_req?.Start(); // Burst the block requests to speed up the restore int burst = (int)Math.Max(options.RestoreChannelBufferSize, 1); int j = 0; for (int i = 0; i < (int)Math.Min(missing_blocks.Count, burst); i++) { await block_request.WriteAsync( new BlockRequest( missing_blocks[i].BlockID, missing_blocks[i].BlockOffset, missing_blocks[i].BlockHash, missing_blocks[i].BlockSize, missing_blocks[i].VolumeID, BlockRequestType.Download ) ).ConfigureAwait(false); } sw_req?.Stop(); if (!options.Dryrun && options.RestorePreAllocate) { // Preallocate the file size to avoid fragmentation / help the operating system / filesystem. fs!.SetLength(file.Length); } for (int i = 0; i < blocks.Length; i++) { // Each response block is not verified against the block hash, as the processes downloading the blocks should do that. if (j < missing_blocks.Count && missing_blocks[j].BlockOffset == i) { // Read the block from the response and issue a new request, if more blocks are missing sw_resp?.Start(); using var datablock = await (await block_response.ReadAsync().ConfigureAwait(false)).ConfigureAwait(false); if (datablock.Data == null) throw new Exception($"Received null data block from request {missing_blocks[j].BlockID} for file {file.TargetPath}"); sw_resp?.Stop(); sw_req?.Start(); if (j < missing_blocks.Count - burst) { await block_request.WriteAsync( new BlockRequest( missing_blocks[j + burst].BlockID, missing_blocks[j + burst].BlockOffset, missing_blocks[j + burst].BlockHash, missing_blocks[j + burst].BlockSize, missing_blocks[j + burst].VolumeID, BlockRequestType.Download ) ).ConfigureAwait(false); } sw_req?.Stop(); // Hash the block to verify the file hash sw_work_hash?.Start(); filehasher.TransformBlock(datablock.Data, 0, (int)blocks[i].BlockSize, datablock.Data, 0); sw_work_hash?.Stop(); if (options.Dryrun) { // Simulate writing the block fs?.Seek(fs.Position + blocks[i].BlockSize, SeekOrigin.Begin); } else { // Write the block to the file sw_work_write?.Start(); await fs!.WriteAsync(datablock.Data.AsMemory(0, (int)blocks[i].BlockSize)).ConfigureAwait(false); sw_work_write?.Stop(); } // Keep track of metrics bytes_written += blocks[i].BlockSize; j++; sw_req?.Start(); await block_request.WriteAsync( new BlockRequest( blocks[i].BlockID, blocks[i].BlockOffset, blocks[i].BlockHash, blocks[i].BlockSize, blocks[i].VolumeID, BlockRequestType.CacheEvict ) ).ConfigureAwait(false); sw_req?.Stop(); } else { // Not a missing block, so read from the file if (fs != null) { sw_work_read?.Start(); var read = await fs .ReadAsync(buffer, 0, buffer.Length) .ConfigureAwait(false); sw_work_read?.Stop(); sw_work_hash?.Start(); filehasher.TransformBlock(buffer, 0, read, buffer, 0); sw_work_hash?.Stop(); } } } if (options.Dryrun) { Logging.Log.WriteDryrunMessage(LOGTAG, "DryrunRestore", @$"Would have restored {bytes_written} bytes of ""{file.TargetPath}"""); } // Verify the file hash sw_work_hash?.Start(); filehasher.TransformFinalBlock([], 0, 0); sw_work_hash?.Stop(); sw_work?.Start(); if (filehasher.Hash != null && Convert.ToBase64String(filehasher.Hash) != file.Hash) { Logging.Log.WriteErrorMessage(LOGTAG, "FileHashMismatch", null, $"File hash mismatch for {file.TargetPath} - expected: {file.Hash}, actual: {Convert.ToBase64String(filehasher.Hash)}"); throw new Exception("File hash mismatch"); } // Truncate the file if it is larger than the expected size. if (fs?.Length > file.Length) { if (options.Dryrun) { Logging.Log.WriteDryrunMessage(LOGTAG, "DryrunRestore", @$"Would have truncated ""{file.TargetPath}"" from {fs.Length} to {file.Length}"); } else { fs.SetLength(file.Length); } } sw_work?.Stop(); } catch (Exception) { lock (results) { results.BrokenLocalFiles.Add(file.TargetPath); } block_request.Retire(); block_response.Retire(); throw; } finally { fs?.Dispose(); } } if (!options.SkipMetadata) { empty_file_or_symlink |= await RestoreMetadata(db, file, restoreDestination, block_request, block_response, options, sw_meta, sw_work_meta, sw_req, sw_resp, results.TaskControl.ProgressToken).ConfigureAwait(false); sw_work_meta?.Stop(); } // TODO legacy restore doesn't count metadata restore as a restored file. sw_work_results?.Start(); if (empty_file_or_symlink || bytes_written > 0) { // Keep track of the restored files and their sizes lock (results) { results.RestoredFiles++; results.SizeOfRestoredFiles += bytes_written; } } sw_work_results?.Stop(); Logging.Log.WriteVerboseMessage(LOGTAG, "RestoredFile", $"{my_id} Restored file {{0}}", file.TargetPath); } } catch (RetiredException) { Logging.Log.WriteVerboseMessage(LOGTAG, "RetiredProcess", null, "File processor retired"); if (options.InternalProfiling) { Logging.Log.WriteProfilingMessage(LOGTAG, "InternalTimings", $"File: {sw_file!.ElapsedMilliseconds}ms, Block: {sw_block!.ElapsedMilliseconds}ms, Meta: {sw_meta!.ElapsedMilliseconds}ms, Req: {sw_req!.ElapsedMilliseconds}ms, Resp: {sw_resp!.ElapsedMilliseconds}ms, Work: {sw_work!.ElapsedMilliseconds}ms, VerifyTarget: {sw_work_verify_target!.ElapsedMilliseconds}ms, Retarget: {sw_work_retarget!.ElapsedMilliseconds}ms, NotifyLocal: {sw_work_notify_local!.ElapsedMilliseconds}ms, VerifyLocal: {sw_work_verify_local!.ElapsedMilliseconds}ms, EmptyFile: {sw_work_empty_file!.ElapsedMilliseconds}ms, Hash: {sw_work_hash!.ElapsedMilliseconds}ms, Read: {sw_work_read!.ElapsedMilliseconds}ms, Write: {sw_work_write!.ElapsedMilliseconds}ms, Results: {sw_work_results!.ElapsedMilliseconds}ms, Meta: {sw_work_meta?.ElapsedMilliseconds}ms"); } return; } catch (Exception ex) { Logging.Log.WriteErrorMessage(LOGTAG, "FileProcessingError", ex, "Error during file processing"); throw; } finally { if (!decremented) lock (file_processor_continue_lock) { file_processors_restoring_files--; if (file_processors_restoring_files <= 0 && !file_processor_continue.Task.IsCompleted) file_processor_continue.SetResult(); } block_request.Retire(); block_response.Retire(); } }); } /// /// Copies the blocks that were verified in the old target to the new target file. /// /// The old target file. /// The new target file. /// The buffer used for copying. /// The restore destination provider. /// The blocks in the old file that were verified. /// The cancellation token. private static async Task CopyOldTargetBlocksToNewTarget(FileRequest old_file, FileRequest new_file, IRestoreDestinationProvider restoreDestination, byte[] buffer, List verified_blocks, CancellationToken cancellationToken) { using var fs_old = await restoreDestination.OpenRead(old_file.TargetPath, cancellationToken).ConfigureAwait(false); using var fs_new = await restoreDestination.OpenWrite(new_file.TargetPath, cancellationToken).ConfigureAwait(false); foreach (var block in verified_blocks) { fs_old.Seek(block.BlockOffset * block.BlockSize, SeekOrigin.Begin); fs_new.Seek(block.BlockOffset * block.BlockSize, SeekOrigin.Begin); var length = await Library.Utility.Utility.ForceStreamReadAsync(fs_old, buffer, buffer.Length, cancellationToken).ConfigureAwait(false); await fs_new.WriteAsync(buffer, 0, length, cancellationToken).ConfigureAwait(false); } } /// /// Generates a new name for the target file. It starts by appending /// the restore time to the file name. If that name is also already /// taken, it appends a number to the name. It keeps incrementing the /// number until it finds a name that is not taken. If it encounters a /// file with the same name and the correct hash, it will retarget the /// filename to that file, assuming that the file is the correct one. /// /// The original target file. /// The restore database. /// The restore destination provider. /// The file hasher used to verify whether any of the /// The cancellation token. /// The new filename. If the file already exists, its file hash matches the target hash. private static async Task GenerateNewName(FileRequest request, LocalRestoreDatabase database, IRestoreDestinationProvider restoreDestination, System.Security.Cryptography.HashAlgorithm filehasher, CancellationToken cancellationToken) { var ext = SystemIO.IO_OS.PathGetExtension(request.TargetPath) ?? ""; if (!string.IsNullOrEmpty(ext) && !ext.StartsWith(".", StringComparison.Ordinal)) ext = "." + ext; // First we try with a simple date append, assuming that there are not many conflicts there var newname = SystemIO.IO_OS.PathChangeExtension(request.TargetPath, null) + "." + database.RestoreTime.ToLocalTime().ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture); var tr = newname + ext; var c = 0; var max_tries = 100; while (await restoreDestination.FileExists(tr, cancellationToken).ConfigureAwait(false) && c < max_tries) { try { // If we have a file with the correct name, // it is most likely the file we want filehasher.Initialize(); string key; using (var file = await restoreDestination.OpenRead(tr, cancellationToken).ConfigureAwait(false)) key = Convert.ToBase64String(filehasher.ComputeHash(file)); if (key == request.Hash) { //TODO: Also needs metadata check to make correct decision. // We stick to the policy to restore metadata in place, if data ok. So, metadata block may be restored. break; } } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "FailedToReadRestoreTarget", ex, "Failed to read candidate restore target {0}", tr); } tr = newname + " (" + (c++).ToString() + ")" + ext; } if (c >= max_tries) { Logging.Log.WriteErrorMessage(LOGTAG, "TooManyConflicts", null, "Too many conflicts when trying to find a new name for the target file"); throw new Exception("Too many conflicts when trying to find a new name for the target file"); } newname = tr; return newname; } /// /// Rendezvous with the other FileProcessor's before processing folder metadata. /// /// An awaitable task that completes once all of the FileProcessor's have rendezvoused. private static async Task RendezvousBeforeProcessingFolderMetadata() { var should_wait = false; lock (file_processor_continue_lock) { file_processors_restoring_files--; if (file_processors_restoring_files <= 0 && !file_processor_continue.Task.IsCompleted) file_processor_continue.SetResult(); should_wait = file_processors_restoring_files > 0; } if (should_wait) { await file_processor_continue.Task.ConfigureAwait(false); } } /// /// Waits for all priority files to be processed before allowing non-priority files to be processed. /// If the current file is a priority file, it decrements the counter and signals completion when all priority files are done. /// If the current file is not a priority file, it waits until all priority files have been processed. /// /// The file request being processed. private static async Task WaitForPriorityFilesIfNeeded(FileRequest file) { if (file.IsPriorityFile) { // This is a priority file - decrement the counter var shouldSignal = false; lock (priority_files_lock) { priority_files_remaining--; if (priority_files_remaining <= 0 && !priority_files_completed.Task.IsCompleted) shouldSignal = true; } if (shouldSignal) { priority_files_completed.SetResult(); } } else { // This is not a priority file - wait for all priority files to complete // Only wait if there are still priority files remaining bool shouldWait; lock (priority_files_lock) { shouldWait = priority_files_remaining > 0; } if (shouldWait) { await priority_files_completed.Task.ConfigureAwait(false); } } } /// /// Restores the metadata for a file. /// /// The command to execute to retrieve the metadata blocks. /// The file to restore. /// The restore destination provider. /// The channel to request blocks from the block manager. /// The channel to receive blocks from the block manager. /// The restore options. /// The stopwatch for internal profiling of the metadata processing. /// The stopwatch for internal profiling of the general processing. /// The stopwatch for internal profiling of the block requests. /// The stopwatch for internal profiling of the block responses. /// The cancellation token to cancel the operation. /// An awaitable `Task`, which returns `true` if the metadata was restored successfully, `false` otherwise. private static async Task RestoreMetadata(LocalRestoreDatabase db, FileRequest file, IRestoreDestinationProvider restoreDestination, IChannel block_request, IChannel> block_response, Options options, Stopwatch? sw_meta, Stopwatch? sw_work, Stopwatch? sw_req, Stopwatch? sw_resp, CancellationToken cancellationToken) { sw_meta?.Start(); // Since each FileProcessor should have its own connection, it's ok // to keep the lock associated with the read transaction for a long // time, as the other processes should still be able to read. // Therefore, we don't need to read the entire result set into // memory. var blocks = db.GetMetadataBlocksFromFile(file.ID, cancellationToken); sw_meta?.Stop(); using var ms = new MemoryStream(); await foreach (var block in blocks.ConfigureAwait(false)) { sw_work?.Stop(); sw_req?.Start(); await block_request.WriteAsync( new BlockRequest( block.BlockID, block.BlockOffset, block.BlockHash, block.BlockSize, block.VolumeID, BlockRequestType.Download ) ).ConfigureAwait(false); sw_req?.Stop(); sw_resp?.Start(); using var datablock = await (await block_response.ReadAsync().ConfigureAwait(false)).ConfigureAwait(false); if (datablock.Data == null) { throw new Exception($"Received null data block from request {block.BlockID} when restoring metadata for file {file.TargetPath}"); } sw_resp?.Stop(); sw_work?.Start(); ms.Write(datablock.Data, 0, (int)block.BlockSize); sw_work?.Stop(); sw_req?.Start(); await block_request.WriteAsync( new BlockRequest( block.BlockID, block.BlockOffset, block.BlockHash, block.BlockSize, block.VolumeID, BlockRequestType.CacheEvict ) ).ConfigureAwait(false); sw_req?.Stop(); sw_work?.Start(); } ms.Seek(0, SeekOrigin.Begin); return await RestoreHandler.ApplyMetadata(file.TargetPath, ms, options, restoreDestination, cancellationToken); } /// /// Verifies the target blocks of a file that may already exist. /// /// The target file. /// The restore destination provider. /// The metadata about the blocks that make up the file. /// A hasher for the file. /// A hasher for a data block. /// A buffer to read and write data blocks. /// The Duplicati configuration options. /// The restoration results. /// The cancellation token. /// An awaitable `Task`, which returns a collection of data blocks that are missing. private static async Task<(long, List, List)> VerifyTargetBlocks(FileRequest file, IRestoreDestinationProvider restoreDestination, BlockRequest[] blocks, System.Security.Cryptography.HashAlgorithm filehasher, System.Security.Cryptography.HashAlgorithm blockhasher, byte[] buffer, Options options, RestoreResults results, CancellationToken cancellationToken) { long bytes_read = 0; List missing_blocks = []; List verified_blocks = []; // Check if the file exists if (File.Exists(file.TargetPath)) { filehasher.Initialize(); try { using var f = await restoreDestination.OpenRead(file.TargetPath, cancellationToken).ConfigureAwait(false); for (int i = 0; i < blocks.Length; i++) { var read = await f.ReadAsync(buffer, 0, (int)blocks[i].BlockSize).ConfigureAwait(false); if (read == blocks[i].BlockSize) { // Block is present filehasher.TransformBlock(buffer, 0, read, buffer, 0); var blockhash = Convert.ToBase64String(blockhasher.ComputeHash(buffer, 0, read)); if (blockhash == blocks[i].BlockHash) { // Block matches bytes_read += read; verified_blocks.Add(blocks[i]); } else { // Block mismatch missing_blocks.Add(blocks[i]); } } else { // Block is missing missing_blocks.Add(blocks[i]); if (f.Position == f.Length) { // No more file - the rest of the blocks are missing. missing_blocks.AddRange(blocks.Skip(i + 1)); break; } } } } catch (Exception) { lock (results) { results.BrokenLocalFiles.Add(file.TargetPath); } throw; } // If all of the individual blocks have been verified. if (missing_blocks.Count == 0) { // Verify the file hash filehasher.TransformFinalBlock([], 0, 0); if (filehasher.Hash != null && Convert.ToBase64String(filehasher.Hash) == file.Hash) { // Truncate the file if it is larger than the expected size. FileInfo fi = new(file.TargetPath); if (file.Length < fi.Length) { if (options.Dryrun) { Logging.Log.WriteDryrunMessage(LOGTAG, "DryrunRestore", @$"Would have truncated ""{file.TargetPath}"" from {fi.Length} to {file.Length}"); } else { // Reopen file with write permission fi.IsReadOnly = false; // The metadata handler will revert this back later. try { using var f = await restoreDestination.OpenWrite(file.TargetPath, cancellationToken).ConfigureAwait(false); f.SetLength(file.Length); } catch (Exception) { lock (results) { results.BrokenLocalFiles.Add(file.TargetPath); } throw; } } } } } } else { // The file doesn't exist, so all blocks are missing. missing_blocks.AddRange(blocks); } return (bytes_read, missing_blocks, verified_blocks); } /// /// Verifies the local blocks at the original restore path that may be used to restore the file. /// /// The file to restore. Contains both the target and original paths. /// The restore destination provider. /// The collection of blocks that are currently missing. /// The total number of blocks for the file. /// A hasher for the file. /// A hasher for the data block. /// A buffer to read and write data blocks. /// The Duplicati configuration options. /// The restoration results. /// The channel to request blocks from the block manager. Used to inform the block manager which blocks are already present. /// The cancellation token. /// An awaitable `Task`, which returns a collection of data blocks that are missing. private static async Task<(long, List)> VerifyLocalBlocks(FileRequest file, IRestoreDestinationProvider restoreDestination, List blocks, long total_blocks, System.Security.Cryptography.HashAlgorithm filehasher, System.Security.Cryptography.HashAlgorithm blockhasher, byte[] buffer, Options options, RestoreResults results, IChannel block_request, CancellationToken cancellationToken) { if (file.TargetPath == file.OriginalPath) { // The original file is the same as the target file, so no new blocks can be used. return (0, blocks); } List missing_blocks = []; List verified_blocks = []; // Check if the file exists if (File.Exists(file.OriginalPath)) { filehasher.Initialize(); // Open both files, as the target file is still being read to produce the overall file hash, if all the blocks are present across both the target and original files. // Note: The original file is the source file on the local system (not within the restore destination), // so we use File.OpenRead directly instead of restoreDestination.OpenRead using var f_original = File.OpenRead(file.OriginalPath); using var f_target = options.Dryrun ? (await restoreDestination.FileExists(file.TargetPath, cancellationToken).ConfigureAwait(false) ? await restoreDestination.OpenRead(file.TargetPath, cancellationToken).ConfigureAwait(false) : null) : await restoreDestination.OpenReadWrite(file.TargetPath, cancellationToken).ConfigureAwait(false); long bytes_read = 0; long bytes_written = 0; int j = 0; for (long i = 0; i < total_blocks; i++) { int read = 0; if (j < blocks.Count && blocks[j].BlockOffset == i) { // The current block is a missing block try { f_original.Seek(i * options.Blocksize, SeekOrigin.Begin); read = await f_original.ReadAsync(buffer, 0, (int)blocks[j].BlockSize).ConfigureAwait(false); } catch (Exception) { lock (results) { results.BrokenLocalFiles.Add(file.TargetPath); } throw; } if (read == blocks[j].BlockSize) { var blockhash = Convert.ToBase64String(blockhasher.ComputeHash(buffer, 0, read)); if (blockhash != blocks[j].BlockHash) { missing_blocks.Add(blocks[j]); } else { if (!options.Dryrun) { if (f_target != null) { try { f_target.Seek(blocks[j].BlockOffset * options.Blocksize, SeekOrigin.Begin); await f_target .WriteAsync(buffer, 0, read) .ConfigureAwait(false); } catch (Exception) { lock (results) { results.BrokenLocalFiles.Add(file.TargetPath); } throw; } } } bytes_read += read; bytes_written += read; await block_request.WriteAsync( new BlockRequest( blocks[j].BlockID, blocks[j].BlockOffset, blocks[j].BlockHash, blocks[j].BlockSize, blocks[j].VolumeID, BlockRequestType.CacheEvict ) ).ConfigureAwait(false); verified_blocks.Add(blocks[j]); } } else { missing_blocks.Add(blocks[j]); if (f_original.Position == f_original.Length) { missing_blocks.AddRange(blocks.Skip(j + 1)); break; } } j++; } else { // The current block is not a missing block - read from the target file. if (f_target != null) { try { f_target.Seek(i * options.Blocksize, SeekOrigin.Begin); read = await f_target .ReadAsync(buffer, 0, options.Blocksize) .ConfigureAwait(false); } catch (Exception) { lock (results) { results.BrokenLocalFiles.Add(file.TargetPath); } throw; } } } filehasher.TransformBlock(buffer, 0, read, buffer, 0); } if (missing_blocks.Count == 0) { // No more blocks are missing, so check the file hash. filehasher.TransformFinalBlock([], 0, 0); if (filehasher.Hash != null && Convert.ToBase64String(filehasher.Hash) == file.Hash) { // Truncate the file if it is larger than the expected size. if (file.Length < f_target?.Length) { if (options.Dryrun) { Logging.Log.WriteDryrunMessage(LOGTAG, "DryrunRestore", @$"Would have truncated ""{file.TargetPath}"" from {f_target.Length} to {file.Length}"); } else { try { f_target.SetLength(file.Length); } catch (Exception) { lock (results) { results.BrokenLocalFiles.Add(file.TargetPath); } throw; } } } } else { Logging.Log.WriteErrorMessage(LOGTAG, "FileHashMismatch", null, $"File hash mismatch for {file.TargetPath} - expected: {file.Hash}, actual: {Convert.ToBase64String(filehasher.Hash ?? [0])}"); lock (results) { results.BrokenLocalFiles.Add(file.TargetPath); } } } if (options.Dryrun) { Logging.Log.WriteDryrunMessage(LOGTAG, "DryrunRestore", @$"Would have restored {verified_blocks.Count} blocks ({bytes_written} bytes) from ""{file.OriginalPath}"" to ""{file.TargetPath}"""); } return (bytes_written, missing_blocks); } else { // The original file is no longer present, so all blocks are missing. return (0, blocks); } } } }