// Copyright (C) 2026, 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.Linq; using System.Threading; using Duplicati.Library.Utility; using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; using Duplicati.Library.Main.Database; using System.Threading.Tasks; using Duplicati.Library.Main.Operation.Common; using System.IO; using Duplicati.Library.Backends; namespace Duplicati.Library.Main { public class Controller : IDisposable, IController { /// /// The tag used for logging /// private static readonly string LOGTAG = Logging.Log.LogTagFromType(); /// /// The backend url /// private string m_backendUrl; /// /// The parsed type-safe version of the commandline options /// private readonly Options m_options; /// /// The optional secret provider, if none provided in options /// public ISecretProvider SecretProvider { get; private set; } /// /// The destination for all output messages during execution /// private IMessageSink m_messageSink; // TODO: The m_current* approach here prevents us from having multiple concurrent operations /// /// The current executing task /// private ITaskControl m_currentTaskControl = null; /// /// The current backend manager /// private IBackendManager m_currentBackendManager = null; /// /// If not null, active locale change that needs to be reset /// private LocaleChange m_localeChange = null; /// /// Callback method invoked when an operation is started /// public Action OnOperationStarted { get; set; } /// /// Callback method invoked when an operation is completed /// public Action OnOperationCompleted { get; set; } /// /// Constructs a new interface for performing backup and restore operations /// /// The url for the backend to use /// All required options public Controller(string backendUrl, Dictionary options, IMessageSink messageSink) { m_backendUrl = backendUrl; m_options = new Options(options); m_messageSink = messageSink; } /// public Task AppendSinkAsync(IMessageSink sink) { if (this.m_messageSink is MultiMessageSink messageSink) messageSink.Append(sink); else m_messageSink = new MultiMessageSink(m_messageSink, sink); return Task.CompletedTask; } /// public Task SetSecretProviderAsync(ISecretProvider secretProvider) { SecretProvider = secretProvider; return Task.CompletedTask; } /// public async Task BackupAsync(string[] inputsources, IFilter inputFilter = null) { UsageReporter.Reporter.Report("USE_BACKEND", new Library.Utility.Uri(m_backendUrl).Scheme); UsageReporter.Reporter.Report("USE_COMPRESSION", m_options.CompressionModule); UsageReporter.Reporter.Report("USE_ENCRYPTION", m_options.EncryptionModule); // Warn about experimental tar-based compression modules if (m_options.CompressionModule.Equals("tzstd", StringComparison.OrdinalIgnoreCase) || m_options.CompressionModule.Equals("tgz", StringComparison.OrdinalIgnoreCase)) { Logging.Log.WriteWarningMessage(LOGTAG, "ExperimentalCompressionModule", null, $"The compression module '{m_options.CompressionModule}' is experimental and for testing only."); } CheckAutoCompactInterval(); CheckAutoVacuumInterval(); return await RunActionAsync(new BackupResults(), inputsources, inputFilter, false, static async config => { using (var h = new Operation.BackupHandler(config.Options, config.Result)) await h.RunAsync(ExpandInputSources(config.Paths, config.Filter, config.Options), config.BackendManager, config.Filter) .ConfigureAwait(false); UsageReporter.Reporter.Report("BACKUP_FILECOUNT", config.Result.ExaminedFiles); UsageReporter.Reporter.Report("BACKUP_FILESIZE", config.Result.SizeOfExaminedFiles); UsageReporter.Reporter.Report("BACKUP_DURATION", (long)config.Result.Duration.TotalSeconds); using (var h = new Operation.RemoteSynchronizationHandler(config.BackendUrl, config.Options, config.Result)) await h.RunAsync() .ConfigureAwait(false); }).ConfigureAwait(false); } /// public async Task RestoreAsync(string[] paths, IFilter inputFilter = null) { return await RunActionAsync(new RestoreResults(), paths, inputFilter, false, static async config => { using var restoreDestination = (config.Options.Restorepath ?? "").StartsWith("@") // Remote destination ? await DynamicLoader.RestoreDestinationProviderLoader.GetRestoreDestinationProvider( config.Options.Restorepath.Substring(1), config.Options.RawOptions, config.Result.TaskControl.ProgressToken) .ConfigureAwait(false) // Local destination : new SourceProvider.FileRestoreDestinationProvider(config.Options.Restorepath ?? "", config.Options.AllowRestoreOutsideTargetDirectory); if (restoreDestination == null) throw new UserInformationException($"Could not find restore destination for path: {config.Options.Restorepath}", "InvalidRestoreDestination"); await new Operation.RestoreHandler(config.Options, config.Result) .RunAsync(config.Paths, config.BackendManager, config.Filter, restoreDestination) .ConfigureAwait(false); await restoreDestination.Finalize((pg) => { config.Result.OperationProgressUpdater.UpdateProgress((float)pg); }, config.Result.TaskControl.ProgressToken).ConfigureAwait(false); config.Result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_Complete); config.Result.EndTime = DateTime.UtcNow; UsageReporter.Reporter.Report("RESTORE_FILECOUNT", config.Result.RestoredFiles); UsageReporter.Reporter.Report("RESTORE_FILESIZE", config.Result.SizeOfRestoredFiles); UsageReporter.Reporter.Report("RESTORE_DURATION", (long)config.Result.Duration.TotalSeconds); }).ConfigureAwait(false); } /// public async Task RestoreControlFilesAsync(IEnumerable files = null, IFilter inputFilter = null) { return await RunActionAsync(new RestoreControlFilesResults(), files?.ToArray(), inputFilter, false, static config => new Operation.RestoreControlFilesHandler(config.Options, config.Result) .RunAsync(config.Paths, config.BackendManager, config.Filter) ).ConfigureAwait(false); } /// public async Task DeleteAsync() { return await RunActionAsync(new DeleteResults(), null, null, false, static config => new Operation.DeleteHandler(config.Options, config.Result) .RunAsync(config.BackendManager) ).ConfigureAwait(false); } /// public async Task RepairAsync(IFilter inputFilter = null) { return await RunActionAsync(new RepairResults(), null, inputFilter, false, static config => new Operation.RepairHandler(config.Options, config.Result) .RunAsync(config.BackendManager, config.Filter) ).ConfigureAwait(false); } /// public async Task ListFilesetsAsync() => await RunActionAsync(new ListFilesetResults(), null, null, false, static config => Operation.ListFilesetsHandler.RunAsync(config.Options, config.Result, config.BackendManager) ).ConfigureAwait(false); /// public async Task ListFolderAsync(string[] folders, long offset, long limit, bool extendedData) => await RunActionAsync(new ListFolderResults(), folders, null, new { offset, limit, extendedData }, static config => Operation.ListFolderHandler.RunAsync(config.Options, config.Result, config.Paths, config.Context.offset, config.Context.limit, config.Context.extendedData) ).ConfigureAwait(false); /// public async Task ListFileVersionsAsync(string[] files, long offset, long limit) => await RunActionAsync(new ListFileVersionsResults(), files, null, new { offset, limit }, static config => Operation.ListFileVersionsHandler.RunAsync(config.Options, config.Result, config.Paths, config.Context.offset, config.Context.limit) ).ConfigureAwait(false); /// public async Task SearchEntriesAsync(string[] pathprefixes, IFilter inputFilter, bool caseSensitive, long offset, long limit, bool extendedData) => await RunActionAsync(new SearchFilesResults(), pathprefixes, inputFilter, new { caseSensitive, offset, limit, extendedData }, static config => Operation.SearchEntriesHandler.RunAsync(config.Options, config.Result, config.Paths, config.Filter, config.Context.caseSensitive, config.Context.offset, config.Context.limit, config.Context.extendedData) ).ConfigureAwait(false); /// public Task ListAsync() => ListAsync(null, null); /// public Task ListAsync(string filterstring) => ListAsync(filterstring == null ? null : new string[] { filterstring }, null); /// public async Task ListAsync(IEnumerable filterstrings, IFilter inputFilter) { return await RunActionAsync(new ListResults(), filterstrings?.ToArray(), inputFilter, false, static config => new Operation.ListFilesHandler(config.Options, config.Result) .RunAsync(config.BackendManager, config.Paths, config.Filter) ).ConfigureAwait(false); } /// public async Task ListControlFilesAsync(IEnumerable filterstrings, IFilter inputFilter) { return await RunActionAsync(new ListResults(), filterstrings?.ToArray(), inputFilter, false, static config => new Operation.ListControlFilesHandler(config.Options, config.Result) .RunAsync(config.BackendManager, config.Paths, config.Filter) ).ConfigureAwait(false); } /// public async Task ListRemoteAsync() { return await RunActionAsync(new ListRemoteResults(), null, null, false, static async config => { using var tf = File.Exists(config.Options.Dbpath) ? null : new TempFile(); await using var db = await LocalDatabase.CreateLocalDatabaseAsync(((string)tf) ?? config.Options.Dbpath, "list-remote", true, null, config.Result.TaskControl.ProgressToken).ConfigureAwait(false); config.Result.SetResult(await config.BackendManager.ListAsync(CancellationToken.None).ConfigureAwait(false)); }).ConfigureAwait(false); } /// public async Task DeleteAllRemoteFilesAsync() { return await RunActionAsync(new ListRemoteResults(), null, null, false, static async config => { config.Result.OperationProgressUpdater.UpdatePhase(OperationPhase.Delete_Listing); { // Only delete files that match the expected pattern and prefix var list = (await config.BackendManager.ListAsync(config.Result.TaskControl.ProgressToken).ConfigureAwait(false)) .Select(x => Volumes.VolumeBase.ParseFilename(x)) .Where(x => x != null) .Where(x => x.Prefix == config.Options.Prefix) .ToList(); // If the local database is available, we will use it to avoid deleting unrelated files // from the backend. Otherwise, we may accidentally delete non-Duplicati files, or // files from a different Duplicati configuration that points to the same backend location // and uses the same prefix (see issues #2678, #3845, and #4244). if (File.Exists(config.Options.Dbpath)) { await using LocalDatabase db = await LocalDatabase.CreateLocalDatabaseAsync(config.Options.Dbpath, "list-remote", true, null, config.Result.TaskControl.ProgressToken).ConfigureAwait(false); var dbRemoteVolumes = db.GetRemoteVolumesAsync(config.Result.TaskControl.ProgressToken); var dbRemoteFiles = await dbRemoteVolumes .Select(x => x.Name) .ToHashSetAsync(cancellationToken: config.Result.TaskControl.ProgressToken) .ConfigureAwait(false); list = list.Where(x => dbRemoteFiles.Contains(x.File.Name) ).ToList(); } config.Result.OperationProgressUpdater.UpdatePhase(OperationPhase.Delete_Deleting); config.Result.OperationProgressUpdater.UpdateProgress(0); for (var i = 0; i < list.Count; i++) { try { if (config.Options.Dryrun) { Logging.Log.WriteDryrunMessage(LOGTAG, "WouldDeleteFile", "Would delete file: {0}", list[i].File.Name); } else { await config.BackendManager.DeleteAsync(list[i].File.Name, list[i].File.Size, true, config.Result.TaskControl.ProgressToken).ConfigureAwait(false); } } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "DeleteFilesetError", ex, "Failed to delete remote file: {0}", list[i].File.Name); } config.Result.OperationProgressUpdater.UpdateProgress((float)i / list.Count); } config.Result.OperationProgressUpdater.UpdateProgress(1); } }).ConfigureAwait(false); } /// public async Task CompactAsync() { CheckAutoVacuumInterval(); return await RunActionAsync(new CompactResults(), null, null, false, static config => new Operation.CompactHandler(config.Options, config.Result) .RunAsync(config.BackendManager) ).ConfigureAwait(false); } /// public async Task SetLocksAsync() { return await RunActionAsync(new SetLockResults(), null, null, false, static config => new Operation.SetLocksHandler(config.Options, config.Result) .RunAsync(config.BackendManager) ).ConfigureAwait(false); } /// public async Task ReadLockInfoAsync() { return await RunActionAsync(new ReadLockInfoResults(), null, null, false, static config => new Operation.ReadLockInfoHandler(config.Options, config.Result) .RunAsync(config.BackendManager) ).ConfigureAwait(false); } /// public async Task UpdateDatabaseWithVersionsAsync(IFilter inputFilter = null) { return await RunActionAsync(new RecreateDatabaseResults(), null, inputFilter, false, static async config => { var filelistfilter = Operation.RestoreHandler.GetNumberedFilelistFilterDelegate(config.Options.Time, config.Options.Version, singleTimeMatch: true); using (var h = new Operation.RecreateDatabaseHandler(config.Options, config.Result)) await h.RunUpdateAsync(config.BackendManager, config.Filter, filelistfilter, null) .ConfigureAwait(false); }).ConfigureAwait(false); } /// public async Task CreateLogDatabaseAsync(string targetpath) { return await RunActionAsync(new CreateLogDatabaseResults(), [targetpath], null, false, static async config => await new Operation.CreateBugReportHandler(config.Paths[0], config.Options, config.Result).RunAsync() ).ConfigureAwait(false); } /// public async Task ListChangesAsync(string baseVersion, string targetVersion, IEnumerable filterstrings = null, IFilter inputFilter = null, Action>> callback = null) { return await RunActionAsync(new ListChangesResults(), [baseVersion, targetVersion], inputFilter, new { filterstrings, callback }, async static config => await new Operation.ListChangesHandler(config.Options, config.Result) .RunAsync(config.Paths[0], config.Paths[1], config.BackendManager, config.Context.filterstrings, config.Filter, config.Context.callback) ).ConfigureAwait(false); } /// public async Task ListAffectedAsync(List args, Action callback = null) { return await RunActionAsync(new ListAffectedResults(), args?.ToArray(), null, new { callback }, static config => new Operation.ListAffected(config.Options, config.Result) .RunAsync(config.Paths, config.Context.callback) ).ConfigureAwait(false); } /// public async Task TestAsync(long samples = 1) { if (!m_options.RawOptions.ContainsKey("full-remote-verification")) m_options.RawOptions["full-remote-verification"] = "true"; return await RunActionAsync(new TestResults(), null, null, new { samples }, static config => new Operation.TestHandler(config.Options, config.Result) .RunAsync(config.Context.samples, config.BackendManager) ).ConfigureAwait(false); } /// public async Task TestFilterAsync(string[] paths, IFilter inputFilter = null) { m_options.RawOptions["dry-run"] = "true"; m_options.RawOptions["dbpath"] = "INVALID!"; // Redirect all messages from the filter to the message sink var filtertag = Logging.Log.LogTagFromType(typeof(Operation.Backup.FileEnumerationProcess)); using (Logging.Log.StartScope(m_messageSink.WriteMessage, x => x.FilterTag.Contains(filtertag))) { return await RunActionAsync(new TestFilterResults(), paths, inputFilter, false, static config => new Operation.TestFilterHandler(config.Options, config.Result) .RunAsync(ExpandInputSources(config.Paths, config.Filter, config.Options), config.Filter) ).ConfigureAwait(false); } } /// public async Task SystemInfoAsync() { return await RunActionAsync(new SystemInfoResults(), null, null, false, static config => Operation.SystemInfoHandler.RunAsync(config.Result) ).ConfigureAwait(false); } /// public async Task PurgeFilesAsync(IFilter inputFilter) { return await RunActionAsync(new PurgeFilesResults(), null, inputFilter, false, static config => new Operation.PurgeFilesHandler(config.Options, config.Result) .RunAsync(config.BackendManager, config.Filter) ).ConfigureAwait(false); } /// public async Task ListBrokenFilesAsync(IFilter inputFilter, Func callbackhandler = null) { return await RunActionAsync(new ListBrokenFilesResults(), null, inputFilter, new { callbackhandler }, static config => new Operation.ListBrokenFilesHandler(config.Options, config.Result) .RunAsync(config.BackendManager, config.Filter, config.Context.callbackhandler) ).ConfigureAwait(false); } /// public async Task PurgeBrokenFilesAsync(IFilter inputFilter) { return await RunActionAsync(new PurgeBrokenFilesResults(), null, inputFilter, false, static config => new Operation.PurgeBrokenFilesHandler(config.Options, config.Result) .RunAsync(config.BackendManager, config.Filter) ).ConfigureAwait(false); } /// public async Task SendMailAsync() { m_options.RawOptions["send-mail-level"] = "all"; m_options.RawOptions["send-mail-any-operation"] = "true"; const string sendMailModuleKey = "sendmail"; m_options.RawOptions.TryGetValue("send-mail-to", out string targetmail); if (string.IsNullOrWhiteSpace(targetmail)) throw new Exception(string.Format("No email specified, please use --{0}", "send-mail-to")); m_options.RawOptions["disable-module"] = string.Join( ",", DynamicLoader.GenericLoader.Modules .Where(m => !string.Equals(m.Key, sendMailModuleKey, StringComparison.OrdinalIgnoreCase) ) .Select(x => x.Key) ); /// Forward all messages from the email module to the message sink var filtertag = Logging.Log.LogTagFromType(DynamicLoader.GenericLoader.GetModule(sendMailModuleKey).GetType()); using (Logging.Log.StartScope(m_messageSink.WriteMessage, x => x.FilterTag.Contains(filtertag))) { return await RunActionAsync(new SendMailResults(), null, null, false, static config => Task.Run(() => { config.Result.Lines = []; Thread.Sleep(5); }) ).ConfigureAwait(false); } } /// public async Task VacuumAsync() { return await RunActionAsync(new VacuumResults(), null, null, false, static config => new Operation.VacuumHandler(config.Options, config.Result) .RunAsync() ).ConfigureAwait(false); } private sealed record RunActionData( TRes Result, TCon Context, string BackendUrl, string[] Paths, Options Options, IFilter Filter, IBackendManager BackendManager); private async Task RunActionAsync(TRes result, string[] paths, IFilter filter, TCon context, Func, Task> method) where TRes : ISetCommonOptions, ITaskControlProvider, Logging.ILogDestination, IBasicResults, IBackendWriterProvider { OnOperationStarted?.Invoke(result); var resultSetter = result as ISetCommonOptions; using (var logTarget = new ControllerMultiLogTarget(result, Logging.LogMessageType.Information, null, m_options.SuppressWarningsFilter)) using (Logging.Log.StartScope(logTarget, null)) { logTarget.AddTarget(m_messageSink, m_options.ConsoleLoglevel, m_options.ConsoleLogFilter); result.MessageSink = m_messageSink; using var httpListener = m_options.LogHttpRequests || m_options.LogSocketData >= 0 ? new NetworkTrafficLogger(m_options.LogHttpRequests, m_options.LogSocketData) : null; try { m_currentTaskControl = result.TaskControl; m_options.MainAction = result.MainOperation; await ApplySecretProviderAsync(CancellationToken.None).ConfigureAwait(false); (paths, filter) = SetupCommonOptions(result, paths, filter, logTarget); Logging.Log.WriteInformationMessage(LOGTAG, "StartingOperation", Strings.Controller.StartingOperationMessage(m_options.MainAction)); using (SlowQueryMonitor.StartMonitoring(m_options.SlowQueryThreshold)) using (new ProcessController(m_options)) using (new Logging.Timer(LOGTAG, string.Format("Run{0}", result.MainOperation), string.Format("Running {0}", result.MainOperation))) using (new CoCoL.IsolatedChannelScope()) using (m_options.ConcurrencyMaxThreads <= 0 ? null : new CoCoL.CappedThreadedThreadPool(m_options.ConcurrencyMaxThreads)) using (var backend = new Backend.BackendManager(m_backendUrl, m_options, result.BackendWriter, result.TaskControl)) { try { backend.UpdateThrottleValues(m_options.MaxUploadPrSecond, m_options.MaxDownloadPrSecond); m_currentBackendManager = backend; var config = new RunActionData(result, context, m_backendUrl, paths, m_options, filter, backend); await method(config).ConfigureAwait(false); } finally { m_currentBackendManager = null; // TODO: Should also have a single shared database connection for all operations // The transactions should be managed inside the connection, and not passed around // This would allow us to pass the database instance to the backend manager // And safeguard against remote operations not being logged in the database // This would also allow us to control the unclean shutdown flag, // by toggling this on start and completion of transfers in the manager, // instead of relying on the operations to correctly toggle the flag if (File.Exists(m_options.Dbpath) && !m_options.NoLocalDb) { try { await using var db = await LocalDatabase.CreateLocalDatabaseAsync(m_options.Dbpath, result.MainOperation.ToString(), true, null, CancellationToken.None).ConfigureAwait(false); await backend.StopRunnerAndFlushMessagesAsync(db).ConfigureAwait(false); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "FailedFlushBackendMessages", ex, "Failed to flush backend messages to database: {0}", ex.Message); } } else { backend.StopRunnerAndDiscardMessages(); } } } if (resultSetter.EndTime.Ticks == 0) resultSetter.EndTime = DateTime.UtcNow; resultSetter.Interrupted = false; if (File.Exists(m_options.Dbpath) && !m_options.Dryrun) { try { await using var db = await LocalDatabase.CreateLocalDatabaseAsync(m_options.Dbpath, null, true, null, CancellationToken.None).ConfigureAwait(false); await db.WriteResultsAndCommitAsync(result, CancellationToken.None).ConfigureAwait(false); await db.PurgeLogDataAsync(m_options.LogRetention, CancellationToken.None).ConfigureAwait(false); await db.PurgeDeletedVolumesAsync(DateTime.UtcNow, CancellationToken.None).ConfigureAwait(false); // Vacuum is done AFTER the results are written to the database // This means that the information about the vacuum is not stored in the database, // but will be reported in the log output and messages sent with any of the reporting modules if (m_options.AutoVacuum && result is IResultsWithVacuum vacuumResults && result is BasicResults basicResults) { try { vacuumResults.VacuumResults = new VacuumResults(basicResults); await new Operation.VacuumHandler(m_options, (VacuumResults)vacuumResults.VacuumResults) .RunAsync().ConfigureAwait(false); } catch (Exception ex) { Logging.Log.WriteVerboseMessage(LOGTAG, "FailedToVacuum", ex, "Failed to vacuum database"); } } } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "FailedWriteOperation", ex, "Failed to write operation results to database: {0}", ex.Message); } } OperationComplete(result, null, filter); Logging.Log.WriteInformationMessage(LOGTAG, "CompletedOperation", Strings.Controller.CompletedOperationMessage(m_options.MainAction)); return result; } // Handle custom abort exception from run-script catch (OperationAbortException oae) { resultSetter.EndTime = DateTime.UtcNow; // Log this as a normal operation, as the script raising the exception, // has already populated either warning or log messages as required Logging.Log.WriteInformationMessage(LOGTAG, "AbortOperation", "Aborting operation by request, requested result: {0}", oae.AbortReason); resultSetter.Interrupted = true; try { // No operation was started in database, so write logs to new operation if (File.Exists(m_options.Dbpath) && !m_options.Dryrun) await using (var db = await LocalDatabase.CreateLocalDatabaseAsync(m_options.Dbpath, result.MainOperation.ToString(), true, null, CancellationToken.None).ConfigureAwait(false)) await db.WriteResultsAndCommitAsync(result, CancellationToken.None).ConfigureAwait(false); } catch (Exception we) { Logging.Log.WriteWarningMessage(LOGTAG, "FailedWriteOperation", we, we.Message); } // Do not propagate the cancel exception OperationComplete(result, null, filter); return result; } catch (Exception ex) { Logging.Log.WriteErrorMessage(LOGTAG, "FailedOperation", ex, Strings.Controller.FailedOperationMessage(m_options.MainAction)); try { if (result is BasicResults basicResults) basicResults.OperationProgressUpdater.UpdatePhase(OperationPhase.Error); resultSetter.Fatal = true; // Write logs to previous operation if database exists if (File.Exists(m_options.Dbpath) && !m_options.Dryrun) await using (var db = await LocalDatabase.CreateLocalDatabaseAsync(m_options.Dbpath, null, true, null, CancellationToken.None).ConfigureAwait(false)) await db.WriteResultsAndCommitAsync(result, CancellationToken.None).ConfigureAwait(false); } catch (Exception we) { Logging.Log.WriteWarningMessage(LOGTAG, "FailedWriteOperation", we, we.Message); } // Report the result, and the failure OperationComplete(result, ex, filter); throw; } finally { m_currentTaskControl = null; } } } /// /// Temporarily pushes the filter into the raw options so modules can read it. /// Returns a disposable that removes the key when disposed. /// /// The options to push the filter into /// The filter to push /// A disposable that removes the key when disposed internal static IDisposable PushFilterToOptions(Options options, IFilter filter) => PushFilterToOptions(options, filter, out var _); /// /// Temporarily pushes the filter into the raw options so modules can read it. /// Returns a disposable that removes the key when disposed. /// /// The options to push the filter into /// The filter to push /// A disposable that removes the key when disposed internal static IDisposable PushFilterToOptions(Options options, IFilter filter, out string filterString) { filterString = filter != null && !filter.Empty ? string.Join(Path.PathSeparator.ToString(), FilterExpression.Serialize(filter)) : null; options.RawOptions["filter"] = filterString; return new FilterOptionScope(options); } /// /// Helper to remove the filter option on dispose /// private sealed class FilterOptionScope : IDisposable { private readonly Options m_opts; public FilterOptionScope(Options opts) => m_opts = opts; public void Dispose() => m_opts?.RawOptions.Remove("filter"); } private void OperationComplete(IBasicResults result, Exception exception, IFilter filter) { using var _ = PushFilterToOptions(m_options, filter); if (m_options?.LoadedModules != null) { foreach (var mx in m_options.LoadedModules) if (mx is IGenericCallbackModule module) try { module.OnFinish(result, exception); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, $"OnFinishError{mx.Key}", ex, "OnFinish callback {0} failed: {1}", mx.Key, ex.Message); } foreach (var mx in m_options.LoadedModules) if (mx is IDisposable disposable) try { disposable.Dispose(); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, $"DisposeError{mx.Key}", ex, "Dispose for {0} failed: {1}", mx.Key, ex.Message); } m_options.ClearLoadedModules(); } if (m_localeChange != null) { m_localeChange.Dispose(); m_localeChange = null; } OnOperationCompleted?.Invoke(result, exception); } private (string[] paths, IFilter filter) SetupCommonOptions(ISetCommonOptions result, string[] paths, IFilter filter, ControllerMultiLogTarget logTarget) { m_options.MainAction = result.MainOperation; switch (m_options.MainAction) { case OperationMode.Backup: break; default: //It only makes sense to enable auto-creation if we are writing files. if (!m_options.RawOptions.ContainsKey("disable-autocreate-folder")) m_options.RawOptions["disable-autocreate-folder"] = "true"; break; } if (string.IsNullOrEmpty(m_options.Dbpath)) m_options.Dbpath = CLIDatabaseLocator.GetDatabasePathForCLI(m_backendUrl, m_options); RestoreOptionsFromExistingDatabase(result.MainOperation); //Load generic modules m_options.ClearLoadedModules(); foreach (var m in DynamicLoader.GenericLoader.Modules) { var active = !m_options.DisableModules.Contains(m.Key, StringComparer.OrdinalIgnoreCase) && (m.LoadAsDefault || m_options.EnableModules.Contains(m.Key, StringComparer.OrdinalIgnoreCase)); if (!active) continue; m_options.AddLoadedModule(DynamicLoader.GenericLoader.GetModule(m.Key)); } // Store the URL connection options separately, as these should only be visible to modules implementing IConnectionModule var conopts = new Dictionary(m_options.RawOptions); var qp = new Library.Utility.Uri(m_backendUrl).QueryParameters; foreach (var k in qp.Keys) conopts[(string)k] = qp[(string)k]; // Make the filter read-n-write able in the generic modules using (PushFilterToOptions(m_options, filter, out var pristinefilter)) { foreach (var mx in m_options.LoadedModules) { if (mx is IConnectionModule) mx.Configure(conopts); else mx.Configure(m_options.RawOptions); if (mx is IGenericSourceModule sourcemodule) { if (sourcemodule.ContainFilesForBackup(paths)) { var sourceoptions = sourcemodule.ParseSourcePaths(ref paths, ref pristinefilter, m_options.RawOptions); foreach (var sourceoption in sourceoptions) m_options.RawOptions[sourceoption.Key] = sourceoption.Value; } } if (mx is IGenericCallbackModule module) module.OnStart(result.MainOperation.ToString(), ref m_backendUrl, ref paths); } // If the filters were changed by a module, read them back in if (pristinefilter != m_options.RawOptions["filter"]) filter = FilterExpression.Deserialize(m_options.RawOptions["filter"].Split(new string[] { Path.PathSeparator.ToString() }, StringSplitOptions.RemoveEmptyEntries)); } if (!string.IsNullOrEmpty(m_options.Logfile)) { var path = Path.GetDirectoryName(Path.GetFullPath(m_options.Logfile)); if (!Directory.Exists(path)) Directory.CreateDirectory(path); logTarget.AddTarget( new Library.Logging.StreamLogDestination(m_options.Logfile), m_options.LogFileLoglevel, m_options.LogFileLogFilter ); } if (m_options.HasTempDir) { Library.Utility.TempFolder.SystemTempPath = m_options.TempDir; } if (m_options.HasForcedLocale) { try { m_localeChange = new LocaleChange(m_options.ForcedLocale); } catch (Exception ex) { Library.Logging.Log.WriteWarningMessage(LOGTAG, "LocaleChangeError", ex, Strings.Controller.FailedForceLocaleError(ex.Message)); } } ValidateOptions(); return (paths, filter); } private void RestoreOptionsFromExistingDatabase(OperationMode operation) { if (m_options.NoLocalDb || string.IsNullOrWhiteSpace(m_options.Dbpath) || !File.Exists(m_options.Dbpath)) return; using var db = LocalDatabase.CreateLocalDatabaseAsync(m_options.Dbpath, operation.ToString(), true, null, CancellationToken.None).Await(); Utility.UpdateOptionsFromDbAsync(db, m_options, CancellationToken.None).Await(); } private async Task ApplySecretProviderAsync(CancellationToken cancellationToken) { var args = new[] { new Library.Utility.Uri(m_backendUrl) }; await SecretProviderHelper.ApplySecretProviderAsync([], args, m_options.RawOptions, TempFolder.SystemTempPath, SecretProvider, cancellationToken).ConfigureAwait(false); // Write back the backend argument, if it was modified by the secret provider m_backendUrl = args[0].ToString(); } /// /// This function will examine all options passed on the commandline, and test for unsupported or deprecated values. /// Any errors will be logged into the statistics module. /// private void ValidateOptions() { // Check if only one of the retention options is set var selectedRetentionOptions = new List(); if (m_options.KeepTime.Ticks > 0) { selectedRetentionOptions.Add("keep-time"); } if (m_options.KeepVersions > 0) { selectedRetentionOptions.Add("keep-versions"); } if (m_options.RetentionPolicy.Any()) { selectedRetentionOptions.Add("retention-policy"); } if (selectedRetentionOptions.Count() > 1) { throw new UserInformationException(string.Format("Setting multiple retention options ({0}) is not permitted", string.Join(", ", selectedRetentionOptions.Select(x => "--" + x))), "MultipleRetentionOptionsNotSupported"); } // Check Prefix if (!string.IsNullOrWhiteSpace(m_options.Prefix) && m_options.Prefix.Contains("-")) throw new UserInformationException("The prefix cannot contain hyphens (-)", "PrefixCannotContainHyphens"); if (m_options.VolumeSize < m_options.Blocksize * 2) throw new UserInformationException("The volume size must be at least twice the block size", "VolumeSizeTooSmall"); //Check validity of retention-policy option value try { foreach (var configEntry in m_options.RetentionPolicy) { if (!configEntry.IsKeepAllVersions() && !configEntry.IsUnlimtedTimeframe() && configEntry.Interval >= configEntry.Timeframe) { throw new Interface.UserInformationException("An interval cannot be bigger than the timeframe it is in", "IntervalCannotBeBiggerThanTimeFrame"); } } } catch (Exception e) // simply reading the option value might also result in an exception due to incorrect formatting { throw new Interface.UserInformationException(string.Format("An error occoured while processing the value of --{0}", "retention-policy"), "RetentionPolicyParseError", e); } //Keep a list of all supplied options var ropts = new Dictionary(m_options.RawOptions); //Keep a list of all supported options var supportedOptions = new Dictionary(); //There are a few internal options that are not accessible from outside, and thus not listed foreach (var s in Options.InternalOptions) supportedOptions[s] = null; //Figure out what module options are supported in the current setup var moduleOptions = new List(); var disabledModuleOptions = new Dictionary(); var loadedModuleKeys = m_options.LoadedModules.Select(k => k.Key).ToHashSet(); foreach (var m in DynamicLoader.GenericLoader.Modules) { if (m.SupportedCommands == null) continue; if (loadedModuleKeys.Contains(m.Key)) moduleOptions.AddRange(m.SupportedCommands); else foreach (var c in m.SupportedCommands) { disabledModuleOptions[c.Name] = m.DisplayName + " (" + m.Key + ")"; if (c.Aliases != null) foreach (string s in c.Aliases) disabledModuleOptions[s] = disabledModuleOptions[c.Name]; } } // Throw url-encoded options into the mix //TODO: This can hide values if both commandline and url-parameters supply the same key var ext = new Library.Utility.Uri(m_backendUrl).QueryParameters; foreach (var k in ext.AllKeys) ropts[k] = ext[k]; //Now run through all supported options, and look for deprecated options foreach (var l in new IEnumerable[] { m_options.SupportedCommands, DynamicLoader.BackendLoader.GetSupportedCommands(m_backendUrl), m_options.NoEncryption ? null : DynamicLoader.EncryptionLoader.GetSupportedCommands(m_options.EncryptionModule), moduleOptions, DynamicLoader.CompressionLoader.GetSupportedCommands(m_options.CompressionModule) }) { if (l != null) foreach (ICommandLineArgument a in l) { if (supportedOptions.ContainsKey(a.Name) && !Options.KnownDuplicates.Contains(a.Name)) Logging.Log.WriteWarningMessage(LOGTAG, "DuplicateOption", null, Strings.Controller.DuplicateOptionNameWarning(a.Name)); supportedOptions[a.Name] = a; if (a.Aliases != null) foreach (string s in a.Aliases) { if (supportedOptions.ContainsKey(s) && !Options.KnownDuplicates.Contains(s)) Logging.Log.WriteWarningMessage(LOGTAG, "DuplicateOption", null, Strings.Controller.DuplicateOptionNameWarning(s)); supportedOptions[s] = a; } if (a.Deprecated) { List aliases = new List(); aliases.Add(a.Name); if (a.Aliases != null) aliases.AddRange(a.Aliases); foreach (string s in aliases) if (ropts.ContainsKey(s)) { string optname = a.Name; if (a.Name != s) optname += " (" + s + ")"; Logging.Log.WriteWarningMessage(LOGTAG, "DeprecatedOption", null, Strings.Controller.DeprecatedOptionUsedWarning(optname, a.DeprecationMessage), null); } } if (a.Type == CommandLineArgument.ArgumentType.Boolean && a.DefaultValue == "true" && !a.Deprecated) Logging.Log.WriteWarningMessage(LOGTAG, "DefaultBooleanOption", null, Strings.Controller.WrongDefaultBooleanOptionWarning(a.Name), null); } } //Now look for options that were supplied but not supported foreach (var s in ropts.Keys) if (!supportedOptions.ContainsKey(s)) if (disabledModuleOptions.ContainsKey(s)) Logging.Log.WriteWarningMessage(LOGTAG, "UnsupportedDisabledModule", null, Strings.Controller.UnsupportedOptionDisabledModuleWarning(s, disabledModuleOptions[s]), null); else Logging.Log.WriteWarningMessage(LOGTAG, "UnsupportedOption", null, Strings.Controller.UnsupportedOptionWarning(s), null); //Look at the value supplied for each argument and see if is valid according to its type foreach (var s in ropts.Keys) { if (supportedOptions.TryGetValue(s, out var arg) && arg != null) { var validationMessage = CommandLineArgumentValidator.ValidateOptionValue(arg, s, ropts[s]); if (!string.IsNullOrWhiteSpace(validationMessage)) Logging.Log.WriteWarningMessage(LOGTAG, "OptionValidationError", null, validationMessage); } } var scheme = Library.Utility.Utility.GuessScheme(m_backendUrl); if (DynamicLoader.BackendLoader.GetSupportedCommands(m_backendUrl) == null && !string.Equals(m_backendUrl, "dummy://", StringComparison.OrdinalIgnoreCase)) { // If the backend is HTTP or HTTPS, give a custom error message if (string.Equals(scheme, "http", StringComparison.OrdinalIgnoreCase) || string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase)) throw new UserInformationException(Strings.Controller.BackendNotSupportedHttpError, "BackendNotSupportedHttp"); throw new UserInformationException(Strings.Controller.BackendNotSupportedError(scheme, string.Join(", ", DynamicLoader.BackendLoader.Keys)), "BackendNotSupported"); } //Inform the user about unmaintained backends if (BackendModules.DeprecatedBackendModules.Contains(scheme)) Logging.Log.WriteWarningMessage(LOGTAG, "BackendDeprecated", null, $"The backend {scheme} is deprecated and should be migrated away from."); //Warn about experimental diskimage filesystem parsing if (m_options.RawOptions.ContainsKey("diskimage-filesystem-parsed")) Logging.Log.WriteWarningMessage(LOGTAG, "ExperimentalDiskImageFilesystemParsed", null, "The option 'diskimage-filesystem-parsed' is experimental and should be used with caution."); //TODO: Based on the action, see if all options are relevant } /// /// Helper method that expands the users chosen source input paths, /// and removes duplicate paths /// /// The input sources. /// The filter. /// The options. /// The expanded and filtered sources. private static string[] ExpandInputSources(string[] inputsources, IFilter filter, Options options) { if (inputsources == null || inputsources.Length == 0) throw new UserInformationException(Strings.Controller.NoSourceFoldersError, "NoSourceFolders"); var sources = new List(inputsources.Length); DriveInfo[] drives = null; //Make sure they all have the same format and exist foreach (var inputsource in inputsources) { List expandedSources = new List(); if (OperatingSystem.IsWindows() && (inputsource.StartsWith("*:", StringComparison.Ordinal) || inputsource.StartsWith("?:", StringComparison.Ordinal))) { // *: drive paths are only supported on Windows clients // Lazily load the drive info drives = drives ?? DriveInfo.GetDrives(); // Replace the drive letter with each available drive string sourcePath = inputsource.Substring(1); foreach (DriveInfo drive in drives) { string expandedSource = drive.Name[0] + sourcePath; Logging.Log.WriteVerboseMessage(LOGTAG, "AddingSourcePathFromWildcard", @"Adding source path ""{0}"" due to wildcard source path ""{1}""", expandedSource, inputsource); expandedSources.Add(expandedSource); } } else if (OperatingSystem.IsWindows() && inputsource.StartsWith(@"\\?\Volume{", StringComparison.OrdinalIgnoreCase)) { // In order to specify a drive by it's volume name, adopt the volume guid path syntax: // \\?\Volume{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX} // The volume guid can be found using the 'mountvol' commandline tool. // However, instead of using this path with Windows APIs directory, it is adapted here to a standard path. Guid volumeGuid; if (Guid.TryParse(inputsource.Substring(@"\\?\Volume{".Length, @"XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX".Length), out volumeGuid)) { string driveLetter = Library.Utility.Utility.GetDriveLetterFromVolumeGuid(volumeGuid); if (!string.IsNullOrEmpty(driveLetter)) { string expandedSource = driveLetter + inputsource.Substring(@"\\?\Volume{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}".Length); Logging.Log.WriteVerboseMessage(LOGTAG, "AddingSourceFromGuid", @"Adding source path ""{0}"" in place of volume guid source path ""{1}""", expandedSource, inputsource); expandedSources.Add(expandedSource); } else { // If we aren't allow to have missing sources, throw an exception indicating we couldn't find a drive where this volume is mounted if (!options.AllowMissingSource) throw new UserInformationException(Strings.Controller.SourceVolumeNameNotFoundError(inputsource, volumeGuid), "MissingSourceFolder"); } } else { // If we aren't allow to have missing sources, throw an exception indicating we couldn't find this volume if (!options.AllowMissingSource) throw new UserInformationException(Strings.Controller.SourceVolumeNameInvalidError(inputsource), "SourceVolumeNameInvalid"); } } else { expandedSources.Add(inputsource); } var foundAnyPaths = false; var unauthorized = false; foreach (var expandedSource in expandedSources) { string source; try { // Check if this is a mounted path if (expandedSource.StartsWith("@")) { // TODO: If the remote source fails to load, // this will be an enumeration warning, but will result // in the backup being recorded without files from the source // Eventually, this could lead to retention deletion, // causing the last backup with the data from the source to be deleted // TODO: We do not honor the PreventEmptySource option here, // as we do not know if the source is empty or not foundAnyPaths = true; sources.Add(expandedSource); continue; } // TODO: This expands "C:" to CWD, but not C:\ source = Path.GetFullPath(expandedSource); } catch (Exception ex) { // Note that we use the original source (with the *) in the error throw new UserInformationException(Strings.Controller.InvalidPathError(expandedSource, ex.Message), "InputSourceInvalid", ex); } var fi = new FileInfo(source); var di = new DirectoryInfo(source); if (fi.Exists || di.Exists) { foundAnyPaths = true; if (!fi.Exists) { // If the directory exists, but is empty, and we are not allowed to have empty sources, throw an error try { if (options.PreventEmptySource && di.Exists && !di.EnumerateFileSystemInfos().Any()) throw new UserInformationException(Strings.Controller.SourceFolderEmptyError(inputsource), "SourceFolderEmpty"); } catch (UnauthorizedAccessException ex) { throw new UserInformationException(Strings.Controller.SourceUnauthorizedError(inputsource), "SourceUnauthorized", ex); } source = Util.AppendDirSeparator(source); } sources.Add(source); } else { try { // Try to get attributes. Returns -1 if source doesn't exist, otherwise throws an exception. // In this case, it is irrelevant to use fileinfo or directoryinfo to retrieve attributes. var unused = fi.Attributes; } catch (UnauthorizedAccessException ex) { Logging.Log.WriteWarningMessage(LOGTAG, "AddingSourceFolder", ex, @"Insufficient permissions to read ""{0}"", skipping", expandedSource); unauthorized = true; } } } // If no paths were found, and we aren't allowed to have missing sources, throw an error if (!foundAnyPaths && !options.AllowMissingSource) { if (unauthorized) throw new UserInformationException(Strings.Controller.SourceUnauthorizedError(inputsource), "SourceUnauthorized"); throw new UserInformationException(Strings.Controller.SourceIsMissingError(inputsource), "SourceIsMissing"); } } //Sanity check for duplicate files/folders ISet pathDuplicates; sources = Library.Utility.Utility.GetUniqueItems(sources, Library.Utility.Utility.ClientFilenameStringComparer, out pathDuplicates).ToList(); foreach (var pathDuplicate in pathDuplicates) Logging.Log.WriteVerboseMessage(LOGTAG, "RemoveDuplicateSource", "Removing duplicate source: {0}", pathDuplicate); //Sanity check for multiple inclusions of the same folder for (int i = 0; i < sources.Count; i++) for (int j = 0; j < sources.Count; j++) if (i != j && sources[i].StartsWith(sources[j], Library.Utility.Utility.ClientFilenameStringComparison) && sources[i].EndsWith(Util.DirectorySeparatorString, Library.Utility.Utility.ClientFilenameStringComparison)) { if (filter != null) { bool excludes; FilterExpression.AnalyzeFilters(filter, out _, out excludes); // If there are no excludes, there is no need to keep the folder as a filter if (excludes) { Logging.Log.WriteVerboseMessage(LOGTAG, "RemovingSubfolderSource", "Removing source \"{0}\" because it is a subfolder of \"{1}\", and using it as an include filter", sources[i], sources[j]); filter = JoinedFilterExpression.Join(new FilterExpression(sources[i]), filter); } else Logging.Log.WriteVerboseMessage(LOGTAG, "RemovingSubfolderSource", "Removing source \"{0}\" because it is a subfolder or subfile of \"{1}\"", sources[i], sources[j]); } else Logging.Log.WriteVerboseMessage(LOGTAG, "RemovingSubfolderSource", "Removing source \"{0}\" because it is a subfolder or subfile of \"{1}\"", sources[i], sources[j]); sources.RemoveAt(i); i--; break; } return sources.ToArray(); } /// public Task PauseAsync(bool alsoTransfers) { var ct = m_currentTaskControl; if (ct != null) ct.Pause(alsoTransfers); return Task.CompletedTask; } /// public Task ResumeAsync() { var ct = m_currentTaskControl; if (ct != null) ct.Resume(); return Task.CompletedTask; } /// public Task StopAsync() { var ct = m_currentTaskControl; if (ct == null) return Task.CompletedTask; Logging.Log.WriteVerboseMessage(LOGTAG, "CancellationRequested", "Cancellation Requested"); ct.Stop(); return Task.CompletedTask; } /// public Task AbortAsync() { m_currentTaskControl?.Terminate(); return Task.CompletedTask; } /// public Task SetThrottleSpeedsAsync(long maxUploadPrSecond, long maxDownloadPrSecond) { // Set these values in the options, in case the backend is not up yet m_options.MaxUploadPrSecond = maxUploadPrSecond; m_options.MaxDownloadPrSecond = maxDownloadPrSecond; m_currentBackendManager?.UpdateThrottleValues(maxUploadPrSecond, maxDownloadPrSecond); return Task.CompletedTask; } /// /// The time of the last compact operation /// private DateTime m_lastCompact; /// /// The time of the last vacuum operation /// private DateTime m_lastVacuum; /// public Task SetLastCompactAsync(DateTime lastCompact) { m_lastCompact = lastCompact; return Task.CompletedTask; } /// public Task SetLastVacuumAsync(DateTime lastVacuum) { m_lastVacuum = lastVacuum; return Task.CompletedTask; } private void CheckAutoCompactInterval() { if (!m_options.NoAutoCompact && (m_lastCompact > DateTime.MinValue) && (m_lastCompact.Add(m_options.AutoCompactInterval) > DateTime.Now)) { Logging.Log.WriteInformationMessage(LOGTAG, "CompactResults", "Skipping auto compaction until {0}", m_lastCompact.Add(m_options.AutoCompactInterval)); m_options.RawOptions["no-auto-compact"] = "true"; } } private void CheckAutoVacuumInterval() { if (m_options.AutoVacuum && (m_lastVacuum > DateTime.MinValue) && (m_lastVacuum.Add(m_options.AutoVacuumInterval) > DateTime.Now)) { Logging.Log.WriteInformationMessage(LOGTAG, "VacuumResults", "Skipping auto vacuum until {0}", m_lastVacuum.Add(m_options.AutoVacuumInterval)); m_options.RawOptions["auto-vacuum"] = "false"; } } #region IDisposable Members /// public void Dispose() { } #endregion } }