Improve missing source handling

This PR changes the defaults when sources are missing. Previously, a missing source would abort the backup, unless `--allow-missing-source` was set.

With this change, if no options are set, a missing source will only trigger a warning.

If the option `--allow-missing-source` is set, no warning is logged.

To get the previous behavior, a new option `--abort-if-source-missing` is introduced that toggles this.

If no sources are found at all, the backup will abort no matter the flags.
This commit is contained in:
Kenneth Skovhede
2026-06-19 09:44:24 +02:00
parent 8a50c72098
commit bfcc2247bd
5 changed files with 170 additions and 30 deletions
+45 -25
View File
@@ -1102,16 +1102,22 @@ namespace Duplicati.Library.Main
}
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)
// Couldn't find a drive where this volume is mounted: abort if requested,
// otherwise warn (unless missing sources are silently allowed)
if (options.AbortIfSourceMissing)
throw new UserInformationException(Strings.Controller.SourceVolumeNameNotFoundError(inputsource, volumeGuid), "MissingSourceFolder");
else if (!options.AllowMissingSource)
Logging.Log.WriteWarningMessage(LOGTAG, "SourceVolumeNotFound", null, Strings.Controller.SourceVolumeNameNotFoundError(inputsource, volumeGuid));
}
}
else
{
// If we aren't allow to have missing sources, throw an exception indicating we couldn't find this volume
if (!options.AllowMissingSource)
// Couldn't parse/find this volume: abort if requested, otherwise warn
// (unless missing sources are silently allowed)
if (options.AbortIfSourceMissing)
throw new UserInformationException(Strings.Controller.SourceVolumeNameInvalidError(inputsource), "SourceVolumeNameInvalid");
else if (!options.AllowMissingSource)
Logging.Log.WriteWarningMessage(LOGTAG, "SourceVolumeInvalid", null, Strings.Controller.SourceVolumeNameInvalidError(inputsource));
}
}
else
@@ -1124,24 +1130,24 @@ namespace Duplicati.Library.Main
foreach (var expandedSource in expandedSources)
{
string source;
// Check if this is a mounted path, avoid logging the source if something fails here
if (expandedSource.StartsWith("@"))
{
// NOTE: 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;
}
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);
}
@@ -1192,13 +1198,23 @@ namespace Duplicati.Library.Main
}
}
// If no paths were found, and we aren't allowed to have missing sources, throw an error
if (!foundAnyPaths && !options.AllowMissingSource)
// If no paths were found, handle according to options
if (!foundAnyPaths)
{
if (unauthorized)
throw new UserInformationException(Strings.Controller.SourceUnauthorizedError(inputsource), "SourceUnauthorized");
if (options.AbortIfSourceMissing)
{
if (unauthorized)
throw new UserInformationException(Strings.Controller.SourceUnauthorizedError(inputsource), "SourceUnauthorized");
throw new UserInformationException(Strings.Controller.SourceIsMissingError(inputsource), "SourceIsMissing");
throw new UserInformationException(Strings.Controller.SourceIsMissingError(inputsource), "SourceIsMissing");
}
else if (!options.AllowMissingSource)
{
if (unauthorized)
throw new UserInformationException(Strings.Controller.SourceUnauthorizedError(inputsource), "SourceUnauthorized");
Logging.Log.WriteWarningMessage(LOGTAG, "SourceIsMissing", null, Strings.Controller.SourceIsMissingWarning(inputsource));
}
}
}
@@ -1237,6 +1253,10 @@ namespace Duplicati.Library.Main
break;
}
// If there is nothing to do, don't run the backup
if (sources.Count == 0)
throw new UserInformationException(Strings.Controller.NoSourcesError, "NoSources");
return sources.ToArray();
}
@@ -176,13 +176,19 @@ namespace Duplicati.Library.Main.Operation
}
catch (Exception ex)
{
if (options.AllowMissingSource)
if (options.AbortIfSourceMissing)
{
throw new UserInformationException($"Failed to load source provider for \"{sanitizedUrl}\": {ex.Message}", "SourceProviderFailed", ex);
}
else if (options.AllowMissingSource)
{
continue;
}
else
{
Log.WriteWarningMessage(LOGTAG, "SourceProviderFailed", ex, "Failed to load source provider for \"{0}\"", sanitizedUrl);
continue;
}
throw new UserInformationException($"Failed to load source provider for \"{sanitizedUrl}\": {ex.Message}", "SourceProviderFailed", ex);
}
// Don't accept missing providers
@@ -204,6 +210,7 @@ namespace Duplicati.Library.Main.Operation
throw;
}
// If no source providers could be resolved the backup would be empty.
if (results.Count == 0)
throw new UserInformationException("No sources were available for the backup", "NoSourcesAvailable");
+5
View File
@@ -424,6 +424,7 @@ namespace Duplicati.Library.Main
new CommandLineArgument("list-sets-only", CommandLineArgument.ArgumentType.Boolean, Strings.Options.ListsetsonlyShort, Strings.Options.ListsetsonlyLong, "false"),
new CommandLineArgument("disable-autocreate-folder", CommandLineArgument.ArgumentType.Boolean, Strings.Options.DisableautocreatefolderShort, Strings.Options.DisableautocreatefolderLong, "false"),
new CommandLineArgument("allow-missing-source", CommandLineArgument.ArgumentType.Boolean, Strings.Options.AllowmissingsourceShort, Strings.Options.AllowmissingsourceLong, "false"),
new CommandLineArgument("abort-if-source-missing", CommandLineArgument.ArgumentType.Boolean, Strings.Options.AbortifsourcemissingShort, Strings.Options.AbortifsourcemissingLong, "false"),
new CommandLineArgument("prevent-empty-source", CommandLineArgument.ArgumentType.Boolean, Strings.Options.PreventemptysourceShort, Strings.Options.PreventemptysourceLong, "false"),
new CommandLineArgument("disable-filetime-check", CommandLineArgument.ArgumentType.Boolean, Strings.Options.DisablefiletimecheckShort, Strings.Options.DisablefiletimecheckLong, "false"),
@@ -1462,6 +1463,10 @@ namespace Duplicati.Library.Main
/// </summary>
public bool AllowMissingSource => GetBool("allow-missing-source");
/// <summary>
/// Gets a flag indicating if missing source elements should cause backups to fail
/// </summary>
public bool AbortIfSourceMissing => GetBool("abort-if-source-missing");
/// <summary>
/// Gets a flag indicating if empty source elements should cause backups to fail
/// </summary>
public bool PreventEmptySource => GetBool("prevent-empty-source");
+6 -2
View File
@@ -31,7 +31,9 @@ namespace Duplicati.Library.Main.Strings
public static string DuplicateOptionNameWarning(string optionname) { return LC.L(@"The option --{0} exists more than once. Please report this to the developers", optionname); }
public static string WrongDefaultBooleanOptionWarning(string optionname) { return LC.L(@"The option --{0} is a boolean option with a default value of true. Please report this to the developers", optionname); }
public static string NoSourceFoldersError { get { return LC.L(@"No source folders specified for backup"); } }
public static string SourceIsMissingError(string foldername) { return LC.L(@"Backup aborted since the source path {0} does not exist. Please verify that the source path exists, or remove the source path from the backup configuration, or set the allow-missing-source option.", foldername); }
public static string SourceIsMissingError(string foldername) { return LC.L(@"Backup aborted since the source path {0} does not exist. Please verify that the source path exists, or remove the source path from the backup configuration, or unset the abort-if-source-missing option.", foldername); }
public static string SourceIsMissingWarning(string foldername) { return LC.L(@"The source path {0} does not exist, skipping", foldername); }
public static string NoSourcesError => LC.L(@"No valid source folders found, aborting backup as it would be empty");
public static string SourceUnauthorizedError(string foldername) { return LC.L(@"Unauthorized to access source folder {0}, aborting backup", foldername); }
public static string UnsupportedOptionDisabledModuleWarning(string optionname, string modulename) { return LC.L(@"The option --{0} is not supported because the module {1} is not currently loaded", optionname, modulename); }
public static string UnsupportedOptionWarning(string optionname) { return LC.L(@"The supplied option --{0} is not supported and will be ignored", optionname); }
@@ -233,8 +235,10 @@ namespace Duplicati.Library.Main.Strings
public static string RemotefilelockdurationShort { get { return LC.L(@"Lock duration for remote backup files"); } }
public static string RetentionPolicyLong { get { return LC.L(@"Use this option to reduce the number of versions that are kept with increasing version age by deleting most of the old backups. The expected format is a comma separated list of colon separated time frame and interval pairs. For example the value ""7D:0s,3M:1D,10Y:2M"" means ""For 7 day keep all backups, for 3 months keep one backup every day, for 10 years one backup every 2nd month and delete every backup older than this."". This option also supports using the specifier ""U"" to indicate an unlimited time interval."); } }
public static string RetentionPolicyShort { get { return LC.L(@"Reduce number of versions by deleting old intermediate backups"); } }
public static string AllowmissingsourceLong { get { return LC.L(@"Use this option to continue even if some source entries are missing."); } }
public static string AllowmissingsourceLong { get { return LC.L(@"Use this option to continue even if some source entries are missing. When this option is set, no warning is emitted for missing sources."); } }
public static string AllowmissingsourceShort { get { return LC.L(@"Ignore missing source elements"); } }
public static string AbortifsourcemissingLong { get { return LC.L(@"Use this option to abort the backup if any source path is missing. This overrides the default behavior of logging a warning and continuing."); } }
public static string AbortifsourcemissingShort { get { return LC.L(@"Abort backup if a source is missing"); } }
public static string PreventemptysourceLong { get { return LC.L(@"Use this option to prevent backups from running if one or more source folders are empty. This is useful to prevent backups from running when the source is not available, such as when a USB drive is not mounted. This only works for filesystem source paths."); } }
public static string PreventemptysourceShort { get { return LC.L(@"Prevent backups from running if source folders are empty"); } }
public static string OverwriteLong { get { return LC.L(@"Use this option to overwrite target files when restoring. If this option is not set, the files will be restored with a timestamp and a number appended."); } }
+104
View File
@@ -0,0 +1,104 @@
// 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.IO;
using System.Linq;
using System.Threading.Tasks;
using Duplicati.Library.Interface;
using NUnit.Framework;
namespace Duplicati.UnitTest
{
public class MissingSourceTest : BasicSetupHelper
{
/// <summary>
/// A source path that is guaranteed not to exist
/// </summary>
private string MissingFolder => Path.Combine(BASEFOLDER, "this-source-does-not-exist") + Path.DirectorySeparatorChar;
/// <summary>
/// Writes a file into the (existing, but empty) DATAFOLDER so it is a valid, non-empty source
/// </summary>
private void PopulateDataFolder()
{
TestUtils.WriteTestFile(Path.Combine(DATAFOLDER, "file.txt"), 1024);
}
[Test]
[Category("Targeted")]
public async Task BackupContinuesWhenSourceIsMissingByDefaultAsync()
{
// With the default options, a missing source should only emit a warning and continue,
// as long as at least one valid source remains.
PopulateDataFolder();
var testopts = TestOptions.Expand(new { });
using var controller = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null);
var result = await controller.BackupAsync(new[] { DATAFOLDER, MissingFolder });
Assert.That(result, Is.Not.Null);
Assert.That(result.Warnings.Any(w => w.Contains("does not exist")), Is.True,
"A warning should be emitted for the missing source");
}
[Test]
[Category("Targeted")]
public void BackupAbortsWhenSourceIsMissingAndAbortOptionSet()
{
// When abort-if-source-missing is set, a missing source should abort the backup.
PopulateDataFolder();
var testopts = TestOptions.Expand(new { abort_if_source_missing = true });
using var controller = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null);
var ex = Assert.ThrowsAsync<UserInformationException>(() => controller.BackupAsync(new[] { DATAFOLDER, MissingFolder }));
Assert.That(ex!.HelpID, Is.EqualTo("SourceIsMissing"));
}
[Test]
[Category("Targeted")]
public async Task BackupDoesNotWarnWhenSourceIsMissingAndAllowOptionSetAsync()
{
// When allow-missing-source is set, a missing source should be silently skipped.
PopulateDataFolder();
var testopts = TestOptions.Expand(new { allow_missing_source = true });
using var controller = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null);
var result = await controller.BackupAsync(new[] { DATAFOLDER, MissingFolder });
Assert.That(result, Is.Not.Null);
Assert.That(result.Warnings.Any(w => w.Contains("does not exist")), Is.False,
"No missing-source warning should be emitted when allow-missing-source is set");
}
[Test]
[Category("Targeted")]
public void BackupAbortsWhenAllSourcesMissing()
{
// If every source is missing, the backup would be empty, so it should abort even
// with the default (non-abort) options.
var testopts = TestOptions.Expand(new { });
using var controller = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null);
var ex = Assert.ThrowsAsync<UserInformationException>(() => controller.BackupAsync(new[] { MissingFolder }));
Assert.That(ex!.HelpID, Is.EqualTo("NoSources"));
}
}
}