using System; using System.Linq; using System.Threading; using System.Threading.Tasks; using Duplicati.Library.Interface; using Duplicati.Library.Main.Volumes; using Duplicati.Library.Utility; using Newtonsoft.Json; namespace Duplicati.Library.Main.Backend; #nullable enable partial class BackendManager { /// /// Represents a pending PUT operation /// private class PutOperation : PendingOperation { /// /// Different states of the operation /// private enum OperationState { /// /// The operation is not started /// None, /// /// The upload has started /// Uploading, /// /// The upload has completed /// Uploaded, /// /// The operation has completed /// Completed } /// /// The log tag for this class /// private static readonly string LOGTAG = Logging.Log.LogTagFromType(); /// /// The operation type /// public override BackendActionType Operation => BackendActionType.Put; /// /// The remote filename backing field /// private string remoteFilename; /// /// The remote filename /// public override string RemoteFilename => remoteFilename; /// /// The local temporary file /// public required TempFile? LocalTempfile { get; set; } /// /// Flag indicating if the file is not to be encrypted /// public required bool Unencrypted { get; set; } /// /// The local filename as a string /// public string LocalFilename => LocalTempfile; /// /// Flag to indicate if the file is not tracked in the database /// public required bool TrackedInDb { get; init; } /// /// The encryption and hashing task, the return value indicates if the hash and size was updated /// private Task<(string Hash, long Size)>? encryptionAndHashingTask; /// /// The state of the operation /// private OperationState operationState = OperationState.None; /// /// The pending index operation, if any /// private (IndexVolumeWriter Writer, PutOperation Operation)? indexOperation; /// /// The original index file, if any /// public required IndexVolumeWriter? OriginalIndexFile { get; init; } /// /// A callback that is invoked when the index volume is finished /// public required Action? IndexVolumeFinishedCallback { get; init; } /// /// Creates a new put operation /// /// The execution context /// True if the operation should wait for completion /// The cancellation token public PutOperation(string remotefilename, ExecuteContext context, bool waitForComplete, CancellationToken cancelToken) : base(context, waitForComplete, cancelToken) { remoteFilename = remotefilename; } /// /// Starts the encryption of the file /// /// The options to use public void StartEncryptionAndHashing() { if (encryptionAndHashingTask != null) throw new Exception("Encryption already started"); // Run detached to allow encrypting while waiting in upload queue encryptionAndHashingTask = Task.Run(() => { if (!Context.Options.NoEncryption && !Unencrypted) { using var enc = DynamicLoader.EncryptionLoader.GetModule(Context.Options.EncryptionModule, Context.Options.Passphrase, Context.Options.RawOptions); if (enc == null) throw new Exception(Strings.BackendMananger.EncryptionModuleNotFound(Context.Options.EncryptionModule)); var tempfile = new TempFile(); enc.Encrypt(LocalFilename, tempfile); DeleteLocalFile(); LocalTempfile = tempfile; } if (!TrackedInDb) return (string.Empty, -1L); return CalculateHashAndSize(); }); } /// /// Deletes the local temporary file /// private void DeleteLocalFile() { try { LocalTempfile?.Dispose(); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "DeleteTemporaryFileError", ex, $"Failed to dispose temporary file: {LocalTempfile}"); } finally { LocalTempfile = null; } } /// /// Calculates the hash and size of the file /// /// The hash and size of the file private (string Hash, long Size) CalculateHashAndSize() { var hash = CalculateFileHash(LocalFilename, Context.Options); var size = new System.IO.FileInfo(LocalFilename).Length; return (hash, size); } /// /// Executes the operation /// /// The backend to use /// The cancellation token /// An awaitable task public override async Task ExecuteAsync(IBackend backend, CancellationToken cancelToken) { // This operation is slightly more complex than the others, as it involves two files. // To keep the rest of the code simpler, the upload treats the pair as a single operation // for the caller, but internally it is two separate operations. // If there is a retry, this function needs to handle a retry in various states // And, if the block file has been renamed, the index file needs to be rewritten if (operationState == OperationState.Completed) throw new Exception("Operation already completed"); // If this is a retry after the block has uploaded, pass the operating to the index file upload if (operationState == OperationState.Uploaded && indexOperation != null) { await indexOperation.Value.Operation.ExecuteAsync(backend, cancelToken).ConfigureAwait(false); indexOperation.Value.Writer.Dispose(); operationState = OperationState.Completed; return true; } // Check if the operation is in a valid state if (operationState == OperationState.Uploaded) throw new Exception("Operation already uploaded"); if (this.encryptionAndHashingTask == null) throw new Exception("Encryption not started"); // On retry attempts, we get the same value, without recalculating (var hash, var size) = await encryptionAndHashingTask.ConfigureAwait(false); // On first upload attempt, calculate the hash and size if (operationState == OperationState.None && TrackedInDb) Context.Database.LogRemoteVolumeUpdated(RemoteFilename, RemoteVolumeState.Uploading, size, hash); // First attempt here, finish the index file now that all information is known if (OriginalIndexFile != null && indexOperation == null) { Context.Database.LogRemoteVolumeUpdated(OriginalIndexFile.RemoteFilename, RemoteVolumeState.Uploading, -1, null); // Prepare an upload operation for the index file var req2 = new PutOperation(OriginalIndexFile.RemoteFilename, Context, false, cancelToken) { LocalTempfile = OriginalIndexFile.TempFile, TrackedInDb = TrackedInDb, Unencrypted = Unencrypted, IndexVolumeFinishedCallback = null, OriginalIndexFile = null }; OriginalIndexFile.FinishVolume(hash, size); IndexVolumeFinishedCallback?.Invoke(); OriginalIndexFile.Close(); indexOperation = (OriginalIndexFile, req2); } // If we have previously attempted to upload, we need to rename the file if (operationState == OperationState.Uploading) RenameFileAfterError(size); // Flag for next attempt, if any operationState = OperationState.Uploading; await PerformUpload(backend, hash, size, cancelToken).ConfigureAwait(false); operationState = OperationState.Uploaded; // Upload completed, prepare the index file if any if (indexOperation != null) { // TODO: It would be better if we encrypt the index file while uploading the block file // since most operations work correctly. But if the upload of the block file fails // we need to deal with decryption, or keep the unencrypted file around indexOperation.Value.Operation.StartEncryptionAndHashing(); await indexOperation.Value.Operation.ExecuteAsync(backend, cancelToken).ConfigureAwait(false); indexOperation.Value.Writer.Dispose(); } operationState = OperationState.Completed; return true; } /// /// Performs the actual upload of a file /// /// The backend to upload to /// The hash of the file /// The size of the file /// The cancellation token /// An awaitable task private async Task PerformUpload(IBackend backend, string Hash, long Size, CancellationToken cancelToken) { Context.Database.LogRemoteOperation("put", RemoteFilename, JsonConvert.SerializeObject(new { Size = Size, Hash = Hash })); Context.Statwriter.SendEvent(BackendActionType.Put, BackendEventType.Started, RemoteFilename, Size); var begin = DateTime.Now; if (backend is IStreamingBackend streamingBackend && !Context.Options.DisableStreamingTransfers) { using var fs = System.IO.File.OpenRead(LocalFilename); using var ts = new ThrottledStream(fs, Context.Options.MaxUploadPrSecond, 0); using var pgs = new ProgressReportingStream(ts, pg => Context.HandleProgress(ts, pg, RemoteFilename)); await streamingBackend.PutAsync(RemoteFilename, pgs, cancelToken).ConfigureAwait(false); } else await backend.PutAsync(RemoteFilename, LocalFilename, cancelToken).ConfigureAwait(false); var duration = DateTime.Now - begin; Logging.Log.WriteProfilingMessage(LOGTAG, "UploadSpeed", "Uploaded {0} in {1}, {2}/s", Library.Utility.Utility.FormatSizeString(Size), duration, Library.Utility.Utility.FormatSizeString((long)(Size / duration.TotalSeconds))); if (TrackedInDb) Context.Database.LogRemoteVolumeUpdated(RemoteFilename, RemoteVolumeState.Uploaded, Size, Hash); Context.Statwriter.SendEvent(BackendActionType.Put, BackendEventType.Completed, RemoteFilename, Size); if (Context.Options.ListVerifyUploads) { var f = await backend.ListAsync(cancelToken).FirstOrDefaultAsync(n => n.Name.Equals(RemoteFilename, StringComparison.OrdinalIgnoreCase)).ConfigureAwait(false); if (f == null) throw new Exception(string.Format($"List verify failed, file was not found after upload: {RemoteFilename}")); else if (f.Size != Size && f.Size >= 0) throw new Exception(string.Format($"List verify failed for file: {f.Name}, size was {f.Size} but expected to be {Size}")); } DeleteLocalFile(); } /// /// Renames the remote target file after an error, and updates the index file, if any /// /// The size of the file private void RenameFileAfterError(long Size) { var p = VolumeBase.ParseFilename(RemoteFilename); var guid = VolumeWriterBase.GenerateGuid(); var time = p.Time.Ticks == 0 ? p.Time : p.Time.AddSeconds(1); var newname = VolumeBase.GenerateFilename(p.FileType, p.Prefix, guid, time, p.CompressionModule, p.EncryptionModule); var oldname = RemoteFilename; Context.Statwriter.SendEvent(BackendActionType.Put, BackendEventType.Rename, oldname, Size); Context.Statwriter.SendEvent(BackendActionType.Put, BackendEventType.Rename, newname, Size); Logging.Log.WriteInformationMessage(LOGTAG, "RenameRemoteTargetFile", "Renaming \"{0}\" to \"{1}\"", oldname, newname); Context.Database.LogRemoteVolumeRenamed(oldname, newname); remoteFilename = newname; // If there is an index file attached to the block file, // it references the block filename, so we create a new index file // which is a copy of the current, but with the new name if (indexOperation != null) { IndexVolumeWriter? wr = null; try { var hashsize = HashFactory.HashSizeBytes(Context.Options.BlockHashAlgorithm); wr = new IndexVolumeWriter(Context.Options); using (var rd = new IndexVolumeReader(p.CompressionModule, indexOperation.Value.Operation.LocalFilename, Context.Options, hashsize)) wr.CopyFrom(rd, x => x == oldname ? newname : x); indexOperation.Value.Writer.Dispose(); indexOperation = (wr, indexOperation.Value.Operation); indexOperation.Value.Operation.LocalTempfile?.Dispose(); indexOperation.Value.Operation.LocalTempfile = wr.TempFile; wr.Close(); } catch { wr?.Dispose(); throw; } } } } }