Merge branch 'master' into feature/lock-down-permissions-on-data-folder
This commit is contained in:
@@ -269,11 +269,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -309,11 +309,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -347,11 +347,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -386,11 +386,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -424,11 +424,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -463,11 +463,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -500,11 +500,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -540,11 +540,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -579,11 +579,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -613,11 +613,11 @@ jobs:
|
||||
- ubuntu-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -646,11 +646,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -684,11 +684,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -720,11 +720,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -758,11 +758,11 @@ jobs:
|
||||
# - macos-latest
|
||||
# steps:
|
||||
# - name: Set up .NET
|
||||
# uses: actions/setup-dotnet@v4
|
||||
# uses: actions/setup-dotnet@v5
|
||||
# with:
|
||||
# dotnet-version: 10.x
|
||||
# - name: Checkout source
|
||||
# uses: actions/checkout@v4
|
||||
# uses: actions/checkout@v7
|
||||
# - name: Restore NuGet dependencies
|
||||
# run: >-
|
||||
# dotnet restore
|
||||
@@ -794,11 +794,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -831,11 +831,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -869,11 +869,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -910,11 +910,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
|
||||
@@ -19,12 +19,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Create dummy changelog file
|
||||
working-directory: ./ReleaseBuilder
|
||||
@@ -41,6 +41,6 @@ jobs:
|
||||
# selenium:
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
# - uses: actions/checkout@v7
|
||||
# - name: Selenium
|
||||
# run: pipeline/selenium/test.sh
|
||||
|
||||
@@ -89,11 +89,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -122,11 +122,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -157,11 +157,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -192,11 +192,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -227,11 +227,11 @@ jobs:
|
||||
- macos-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
@@ -252,11 +252,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Restore NuGet dependencies
|
||||
run: >-
|
||||
dotnet restore
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
issues: write
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
days-before-issue-stale: 15
|
||||
days-before-issue-close: 15
|
||||
|
||||
@@ -19,12 +19,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Restore NuGet dependencies
|
||||
run: dotnet restore Duplicati.slnx
|
||||
@@ -49,12 +49,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Restore NuGet dependencies
|
||||
run: dotnet restore Duplicati.slnx
|
||||
@@ -71,13 +71,13 @@ jobs:
|
||||
name: Playwright UI tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/setup-dotnet@v4
|
||||
- uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.x
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Install NPM dependencies
|
||||
run: npm ci
|
||||
- name: Install Playwright browsers
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright test results on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-test-results
|
||||
path: |
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright HTML report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-html-report
|
||||
path: playwright-report/
|
||||
|
||||
@@ -498,6 +498,17 @@ namespace Duplicati.CommandLine
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static int Find(TextWriter outwriter, Action<Duplicati.Library.Main.Controller> setup, List<string> args, Dictionary<string, string> options, Library.Utility.IFilter filter)
|
||||
{
|
||||
// Unlike "list" (which shows only the newest version by default), "find" searches every
|
||||
// backup version, so a file is located regardless of which version it appears in.
|
||||
// Respect an explicit --all-versions / --version / --time supplied by the user.
|
||||
if (!options.ContainsKey("all-versions") && !options.ContainsKey("version") && !options.ContainsKey("time"))
|
||||
options["all-versions"] = "true";
|
||||
|
||||
return List(outwriter, setup, args, options, filter);
|
||||
}
|
||||
|
||||
public static int List(TextWriter outwriter, Action<Duplicati.Library.Main.Controller> setup, List<string> args, Dictionary<string, string> options, Library.Utility.IFilter filter)
|
||||
{
|
||||
filter = filter ?? new Duplicati.Library.Utility.FilterExpression();
|
||||
|
||||
@@ -76,7 +76,7 @@ namespace Duplicati.CommandLine
|
||||
["help"] = Commands.Help,
|
||||
["example"] = Commands.Examples,
|
||||
["examples"] = Commands.Examples,
|
||||
["find"] = Commands.List,
|
||||
["find"] = Commands.Find,
|
||||
["list"] = Commands.List,
|
||||
["list-filesets"] = Commands.ListFilesets,
|
||||
["list-folder-content"] = Commands.ListFolderContent,
|
||||
@@ -107,7 +107,7 @@ namespace Duplicati.CommandLine
|
||||
["readlockinfo"] = Commands.ReadLockInfo,
|
||||
["system-info"] = Commands.SystemInfo,
|
||||
["systeminfo"] = Commands.SystemInfo,
|
||||
["send-mail"] = Commands.SendMail,
|
||||
["send-mail"] = Commands.SendMail,
|
||||
["sync"] = Commands.Sync
|
||||
};
|
||||
|
||||
@@ -393,15 +393,25 @@ namespace Duplicati.CommandLine
|
||||
foreach (KeyValuePair<String, String> keyvalue in opt)
|
||||
options[keyvalue.Key] = keyvalue.Value;
|
||||
|
||||
var command = cargs.Count >= 1 ? cargs[0] : string.Empty;
|
||||
var isBackup = command.Equals("backup", StringComparison.OrdinalIgnoreCase);
|
||||
var isTestFilters = command.Equals("test-filters", StringComparison.OrdinalIgnoreCase)
|
||||
|| command.Equals("test-filter", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!string.IsNullOrEmpty(newtarget))
|
||||
{
|
||||
if (cargs.Count <= 1)
|
||||
// test-filters takes source paths as positional arguments and has no target, so a
|
||||
// --target from the parameters file must not become a bogus positional source (#4812).
|
||||
if (isTestFilters)
|
||||
Library.Logging.Log.WriteVerboseMessage(LOGTAG, "NotUsingTarget", Strings.Program.SkippingTargetArgumentOnTestFilters);
|
||||
else if (cargs.Count <= 1)
|
||||
cargs.Add(newtarget);
|
||||
else
|
||||
cargs[1] = newtarget;
|
||||
}
|
||||
|
||||
if (cargs.Count >= 1 && cargs[0].Equals("backup", StringComparison.OrdinalIgnoreCase))
|
||||
// backup and test-filters take source paths as positional arguments.
|
||||
if (isBackup || isTestFilters)
|
||||
cargs.AddRange(newsource);
|
||||
else if (newsource.Count > 0)
|
||||
Library.Logging.Log.WriteVerboseMessage(LOGTAG, "NotUsingBackupSources", Strings.Program.SkippingSourceArgumentsOnNonBackupOperation);
|
||||
|
||||
@@ -54,6 +54,7 @@ namespace Duplicati.CommandLine.Strings
|
||||
public static string QuietConsoleOptionLong { get { return LC.L(@"If this option is set, progress reports and other messages that would normally go to the console will be redirected to the log."); } }
|
||||
public static string QuietConsoleOptionShort { get { return LC.L(@"Disable console output"); } }
|
||||
public static string SkippingSourceArgumentsOnNonBackupOperation { get { return @"The --source argument was specified in the parameter file, but the current operation is not a backup operation, so the argument is ignored."; } }
|
||||
public static string SkippingTargetArgumentOnTestFilters { get { return @"The --target argument was specified in the parameter file, but the test-filters operation has no target, so the argument is ignored."; } }
|
||||
public static string AutoUpdateOptionShort { get { return LC.L(@"Toggle automatic updates"); } }
|
||||
public static string AutoUpdateOptionLong { get { return LC.L(@"Set this option if you prefer to have the commandline version automatically update"); } }
|
||||
public static string PortableModeOptionShort { get { return LC.L(@"Use portable mode"); } }
|
||||
|
||||
@@ -103,7 +103,7 @@ Usage: %CLI_EXE% backup <storage-URL> "<source-path>" [<options>]
|
||||
|
||||
Usage: %CLI_EXE% find <storage-URL> ["<filename>"] [<options>]
|
||||
|
||||
Finds specific files in specific backups. If <filename> is specified, all occurrences of <filename> in the backup are listed. <filename> can contain * and ? as wildcards. File names in [brackets] are interpreted as regular expression. Latest backup is searched by default. If entire path is specified, all available versions of the file are listed. If no <filename> is specified, a list of all available backups is shown.
|
||||
Finds specific files in specific backups. If <filename> is specified, all occurrences of <filename> in the backup are listed. <filename> can contain * and ? as wildcards. File names in [brackets] are interpreted as regular expression. The "find" command searches all backup versions by default, while "list" shows only the latest version by default (use --all-versions with "list" to search every version). If no <filename> is specified, a list of all available backups is shown.
|
||||
|
||||
--time=<time>
|
||||
Shows what the files looked like at a specific time. Absolute and relative times can be specified.
|
||||
|
||||
@@ -365,11 +365,7 @@ namespace Duplicati.Library.Backend
|
||||
if (m_httpClient == null)
|
||||
{
|
||||
var httpHandler = new HttpClientHandler();
|
||||
|
||||
// Custom certificate validation to throw exception on failure
|
||||
var validator = new SslCertificateValidator(m_certificateOptions.AcceptAllCertificates, m_certificateOptions.AcceptSpecificCertificateHashes, m_certificateOptions.IgnoreRevocationFailure);
|
||||
httpHandler.ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) =>
|
||||
validator.ValidateServerCertificate(sender, cert, chain, sslPolicyErrors);
|
||||
HttpClientHelper.ConfigureHandlerCertificateValidator(httpHandler, m_certificateOptions.AcceptAllCertificates, m_certificateOptions.AcceptSpecificCertificateHashes, m_certificateOptions.IgnoreRevocationFailure);
|
||||
|
||||
if (m_useIntegratedAuthentication)
|
||||
{
|
||||
|
||||
@@ -273,4 +273,35 @@ namespace Duplicati.Library.Interface
|
||||
: base(message, helpId, innerException)
|
||||
{ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception indicating that the backup database is currently locked by another running operation
|
||||
/// and cannot be accessed concurrently.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class DatabaseLockedException : UserInformationException
|
||||
{
|
||||
/// <summary>
|
||||
/// The path to the database file that is locked.
|
||||
/// </summary>
|
||||
public readonly string DatabasePath;
|
||||
|
||||
public DatabaseLockedException(string databasePath)
|
||||
: base(LC.L($"The backup database is currently in use by another operation and cannot be accessed concurrently. Please wait for the running operation to finish and try again."), "DatabaseLocked")
|
||||
{
|
||||
DatabasePath = databasePath;
|
||||
}
|
||||
|
||||
public DatabaseLockedException(string databasePath, Exception innerException)
|
||||
: base(LC.L($"The backup database is currently in use by another operation and cannot be accessed concurrently. Please wait for the running operation to finish and try again."), "DatabaseLocked", innerException)
|
||||
{
|
||||
DatabasePath = databasePath;
|
||||
}
|
||||
|
||||
public DatabaseLockedException(string message, string helpId, Exception innerException)
|
||||
: base(message, helpId, innerException)
|
||||
{
|
||||
DatabasePath = string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,7 +309,7 @@ namespace Duplicati.Library.Interface
|
||||
IEnumerable<IListResultRemoteVolume> RemoteVolumes { get; }
|
||||
}
|
||||
|
||||
public interface IDeleteResults : IBasicResults
|
||||
public interface IDeleteResults : IBasicResults, IBackendStatsticsReporter
|
||||
{
|
||||
IEnumerable<Tuple<long, DateTime>> DeletedSets { get; }
|
||||
ICompactResults CompactResults { get; }
|
||||
@@ -377,7 +377,7 @@ namespace Duplicati.Library.Interface
|
||||
IEnumerable<IFileEntry> Files { get; }
|
||||
}
|
||||
|
||||
public interface ICompactResults : IBasicResults, IResultsWithVacuum
|
||||
public interface ICompactResults : IBasicResults, IResultsWithVacuum, IBackendStatsticsReporter
|
||||
{
|
||||
long DeletedFileCount { get; }
|
||||
long DownloadedFileCount { get; }
|
||||
|
||||
@@ -121,17 +121,40 @@ internal class ReusableTransaction(SqliteConnection con, SqliteTransaction? tran
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logging.Log.WriteErrorMessage(LOGTAG, "ReusableTransaction dispose", ex, "Transaction disposed with error: {0}", ex.Message);
|
||||
throw;
|
||||
if (!IsInactiveTransactionException(ex))
|
||||
{
|
||||
Logging.Log.WriteErrorMessage(LOGTAG, "ReusableTransaction dispose", ex, "Transaction disposed with error: {0}", ex.Message);
|
||||
throw;
|
||||
}
|
||||
|
||||
Logging.Log.WriteWarningMessage(LOGTAG, "ReusableTransactionAlreadyCompleted", ex, "Transaction was already completed during dispose: {0}", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
m_disposed = true;
|
||||
await m_transaction.DisposeAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await m_transaction.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!IsInactiveTransactionException(ex))
|
||||
throw;
|
||||
|
||||
Logging.Log.WriteWarningMessage(LOGTAG, "ReusableTransactionAlreadyDisposed", ex, "Transaction was already completed before dispose: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Microsoft.Data.Sqlite throws InvalidOperationException ("This SqliteTransaction has completed;
|
||||
// it is no longer usable.") when a transaction is rolled back or disposed after it has already
|
||||
// been completed. Unfortunately there is nothing specific that can be used to differentiate it
|
||||
// from other exceptions that might occur during disposal, so we rely on the exception type, source and message.
|
||||
private static bool IsInactiveTransactionException(Exception ex)
|
||||
=> ex is InvalidOperationException
|
||||
&& ex.Message.StartsWith("This SqliteTransaction has completed", StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Rolls back the transaction and restarts it.
|
||||
/// </summary>
|
||||
|
||||
@@ -211,6 +211,7 @@ public class CompactResultsDto : BasicResultsDto
|
||||
public long UploadedFileSize { get; set; }
|
||||
public bool Dryrun { get; set; }
|
||||
public VacuumResultsDto VacuumResults { get; set; }
|
||||
public BackendStatisticsDto BackendStatistics { get; set; }
|
||||
|
||||
public static CompactResultsDto FromResults(ICompactResults results)
|
||||
{
|
||||
@@ -232,7 +233,8 @@ public class CompactResultsDto : BasicResultsDto
|
||||
DownloadedFileSize = results.DownloadedFileSize,
|
||||
UploadedFileSize = results.UploadedFileSize,
|
||||
Dryrun = results.Dryrun,
|
||||
VacuumResults = VacuumResultsDto.FromResults(results.VacuumResults)
|
||||
VacuumResults = VacuumResultsDto.FromResults(results.VacuumResults),
|
||||
BackendStatistics = BackendStatisticsDto.FromResults(results.BackendStatistics)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -310,6 +312,7 @@ public class DeleteResultsDto : BasicResultsDto
|
||||
{
|
||||
public List<Tuple<long, DateTime>> DeletedSets { get; set; } = new();
|
||||
public CompactResultsDto CompactResults { get; set; }
|
||||
public BackendStatisticsDto BackendStatistics { get; set; }
|
||||
public bool Dryrun { get; set; }
|
||||
|
||||
public static DeleteResultsDto FromResults(IDeleteResults results)
|
||||
@@ -327,6 +330,7 @@ public class DeleteResultsDto : BasicResultsDto
|
||||
Interrupted = results.Interrupted,
|
||||
DeletedSets = results.DeletedSets?.ToList() ?? new List<Tuple<long, DateTime>>(),
|
||||
CompactResults = CompactResultsDto.FromResults(results.CompactResults),
|
||||
BackendStatistics = BackendStatisticsDto.FromResults(results.BackendStatistics),
|
||||
Dryrun = results.Dryrun
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,6 +146,8 @@ public class CompactResultsWrapper : BasicResultsWrapper, ICompactResults
|
||||
get => _dto.VacuumResults == null ? null : new VacuumResultsWrapper(_dto.VacuumResults);
|
||||
set => throw new InvalidOperationException("Cannot set property on wrapper");
|
||||
}
|
||||
|
||||
public IBackendStatstics BackendStatistics => _dto.BackendStatistics == null ? null : new BackendStatisticsWrapper(_dto.BackendStatistics);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -188,6 +190,8 @@ public class DeleteResultsWrapper : BasicResultsWrapper, IDeleteResults
|
||||
public IEnumerable<Tuple<long, DateTime>> DeletedSets => _dto.DeletedSets;
|
||||
public ICompactResults CompactResults => _dto.CompactResults == null ? null : new CompactResultsWrapper(_dto.CompactResults);
|
||||
public bool Dryrun => _dto.Dryrun;
|
||||
|
||||
public IBackendStatstics BackendStatistics => _dto.BackendStatistics == null ? null : new BackendStatisticsWrapper(_dto.BackendStatistics);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -42,14 +42,18 @@ public static class LogExceptionHelper
|
||||
/// <param name="message">The message to log</param>
|
||||
public static void LogCommonWarning(Exception? ex, string logtag, string id, string path, string message = "Failed to process path: {0}")
|
||||
{
|
||||
// For known, expected path problems the warning message already describes the cause,
|
||||
// so the exception (and its stack trace) is not attached: it would only bloat the logs
|
||||
// (issue #6426). The fallback below keeps the exception so genuinely unexpected failures
|
||||
// remain fully debuggable.
|
||||
if (ex.IsPermissionDeniedException())
|
||||
Log.WriteWarningMessage(logtag, "PermissionDenied", ex, "Excluding path due to permission denied: {0}", path);
|
||||
Log.WriteWarningMessage(logtag, "PermissionDenied", null, "Excluding path due to permission denied: {0}", path);
|
||||
else if (ex.IsFileLockedException())
|
||||
Log.WriteWarningMessage(logtag, "FileLocked", ex, "Excluding path due to file locked: {0}", path);
|
||||
Log.WriteWarningMessage(logtag, "FileLocked", null, "Excluding path due to file locked: {0}", path);
|
||||
else if (ex.IsPathNotFoundException())
|
||||
Log.WriteWarningMessage(logtag, "PathNotFound", ex, "Excluding path due to path not found: {0}", path);
|
||||
Log.WriteWarningMessage(logtag, "PathNotFound", null, "Excluding path due to path not found: {0}", path);
|
||||
else if (ex.IsPathTooLongException())
|
||||
Log.WriteWarningMessage(logtag, "PathTooLong", ex, "Excluding path due to path too long: {0}", path);
|
||||
Log.WriteWarningMessage(logtag, "PathTooLong", null, "Excluding path due to path too long: {0}", path);
|
||||
else
|
||||
Log.WriteWarningMessage(logtag, id, ex, message, path);
|
||||
|
||||
|
||||
@@ -64,16 +64,48 @@ namespace Duplicati.Library.Main.Operation
|
||||
if (File.Exists(path))
|
||||
throw new UserInformationException(string.Format("Cannot recreate database because file already exists: {0}", path), "RecreateTargetDatabaseExists");
|
||||
|
||||
await using var db =
|
||||
await LocalDatabase.CreateLocalDatabaseAsync(path, "Recreate", true, null, m_result.TaskControl.ProgressToken)
|
||||
try
|
||||
{
|
||||
await using var db =
|
||||
await LocalDatabase.CreateLocalDatabaseAsync(path, "Recreate", true, null, m_result.TaskControl.ProgressToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await DoRunAsync(backendManager, db, false, filter, filelistfilter, blockprocessor).ConfigureAwait(false);
|
||||
|
||||
// Ensure database is consistent after the recreate
|
||||
await db
|
||||
.VerifyConsistencyAsync(m_options.Blocksize, m_options.BlockhashSize, true, m_result.TaskControl.ProgressToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (UserInformationException uex) when (uex.HelpID == "EmptyRemoteLocation" || uex.HelpID == "EmptyRemoteLocationWithPrefix")
|
||||
{
|
||||
// The remote destination is empty (#6205): the freshly-created database is empty and
|
||||
// would block further operations - a fresh backup cannot run, and even a retry fails
|
||||
// because RunAsync refuses to run when the file already exists. Remove it so the next
|
||||
// run starts cleanly. The database connection is already disposed by the await-using
|
||||
// above (the using scope is left before this catch runs), so the file is no longer
|
||||
// locked.
|
||||
//
|
||||
// Only the empty-destination case is cleaned up. Other recreate failures - notably a
|
||||
// database with missing blocks / broken filelists (help id "DatabaseIsBrokenConsiderPurge")
|
||||
// - intentionally KEEP the recreated database so the user can run list-broken-files /
|
||||
// purge-broken-files against it; those exceptions do not match this filter and propagate
|
||||
// with the database left in place. This is best-effort and must never mask the error.
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
Logging.Log.WriteInformationMessage(LOGTAG, "RemovedIncompleteRecreateDatabase", "Removed incomplete recreate database at {0}", path);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logging.Log.WriteWarningMessage(LOGTAG, "RecreateCleanupFailed", ex, "Failed to remove incomplete recreate database at {0}: {1}", path, ex.Message);
|
||||
}
|
||||
|
||||
await DoRunAsync(backendManager, db, false, filter, filelistfilter, blockprocessor).ConfigureAwait(false);
|
||||
|
||||
// Ensure database is consistent after the recreate
|
||||
await db
|
||||
.VerifyConsistencyAsync(m_options.Blocksize, m_options.BlockhashSize, true, m_result.TaskControl.ProgressToken)
|
||||
.ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -99,10 +99,32 @@ public interface IQueueRunnerService
|
||||
/// <returns>A list of tuples containing the task ID and backup ID</returns>
|
||||
IList<Tuple<long, string?>> GetQueueWithIds();
|
||||
|
||||
/// <summary>
|
||||
/// Cancels the database lock wait for the currently running task, if its ID matches
|
||||
/// <paramref name="taskID"/> and it is still waiting to acquire the database lock.
|
||||
/// If the task has already acquired the lock and is executing, this has no effect —
|
||||
/// call <see cref="IQueuedTask.AbortAsync"/> or <see cref="IQueuedTask.StopAsync"/>
|
||||
/// to interrupt a running task. This method is intended to be called *before*
|
||||
/// <see cref="IQueuedTask.AbortAsync"/>/<see cref="IQueuedTask.StopAsync"/> so that
|
||||
/// a task blocked on lock acquisition is unblocked rather than left stuck.
|
||||
/// </summary>
|
||||
/// <param name="taskID">The ID of the currently running task whose lock wait should be cancelled.</param>
|
||||
void CancelCurrentTaskLockWait(long taskID);
|
||||
|
||||
/// <summary>
|
||||
/// Runs a task immediately, bypassing the queue.
|
||||
/// Note that the task will run concurrently with the queue tasks and may cause database lock issues.
|
||||
/// If the task's backup database is already in use by a queued task (e.g. a running backup),
|
||||
/// this method throws <see cref="Duplicati.Library.Interface.DatabaseLockedException"/> immediately
|
||||
/// rather than waiting or causing a concurrent "database is locked" error.
|
||||
/// <para>
|
||||
/// Only tasks that expose a database path via <see cref="Duplicati.Library.RestAPI.Runner.GetEffectiveDBPath"/>
|
||||
/// (i.e. <see cref="Duplicati.Library.RestAPI.IRunnerData"/>-backed tasks) are protected by the
|
||||
/// database lock. In production all tasks are <see cref="Duplicati.Library.RestAPI.IRunnerData"/>
|
||||
/// instances; non-runner <see cref="IQueuedTask"/> implementations (test mocks) return a
|
||||
/// <c>null</c> path and bypass locking.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="task">The task to run</param>
|
||||
/// <exception cref="Duplicati.Library.Interface.DatabaseLockedException">Thrown when the backup database is already in use.</exception>
|
||||
Task<IBasicResults?> RunImmediatelyAsync(IQueuedTask task);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1164,7 +1164,7 @@ namespace Duplicati.Server
|
||||
|
||||
private static void UpdateMetadataStatistics(IBackup backup, IParsedBackendStatistics r)
|
||||
{
|
||||
if (r != null)
|
||||
if (r != null && r.RemoteCalls > 0)
|
||||
{
|
||||
backup.Metadata["LastBackupDate"] = Utility.SerializeDateTime(r.LastBackupDate.ToUniversalTime());
|
||||
backup.Metadata["BackupListCount"] = r.BackupListCount.ToString();
|
||||
@@ -1217,6 +1217,16 @@ namespace Duplicati.Server
|
||||
UpdateMetadataLastSync(backup, r6);
|
||||
}
|
||||
|
||||
if (result is IDeleteResults r7)
|
||||
{
|
||||
if (r7.CompactResults != null)
|
||||
{
|
||||
UpdateMetadataLastCompact(backup, r7.CompactResults);
|
||||
if (!r7.CompactResults.Interrupted && r7.CompactResults.DeletedFileCount > 0 && r7.CompactResults.BackendStatistics is IParsedBackendStatistics p)
|
||||
UpdateMetadataStatistics(backup, p);
|
||||
}
|
||||
}
|
||||
|
||||
if (result is IBackupResults r)
|
||||
{
|
||||
if (!result.Interrupted)
|
||||
@@ -1412,6 +1422,62 @@ namespace Duplicati.Server
|
||||
options["remote-sync-json-config"] = JsonSerializer.Serialize(config, jsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the effective local database path for an <see cref="IQueuedTask"/> instance.
|
||||
/// In production, all queued tasks are <see cref="IRunnerData"/> instances created by the
|
||||
/// <c>Runner.Create*</c> factories, so this delegates to
|
||||
/// <see cref="GetEffectiveDBPath(IRunnerData?)"/>. If the task is not an
|
||||
/// <see cref="IRunnerData"/> (only possible in tests with mock <see cref="IQueuedTask"/>
|
||||
/// implementations), the result is <c>null</c>.
|
||||
/// </summary>
|
||||
/// <returns>The effective path, or <c>null</c></returns>
|
||||
public static string? GetEffectiveDBPath(IQueuedTask? runnerData)
|
||||
=> GetEffectiveDBPath(runnerData as IRunnerData);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the effective local database path for a <see cref="IRunnerData"/> instance.
|
||||
/// The precedence mirrors <see cref="ApplyOptions"/>: the <c>dbpath</c> extra option takes
|
||||
/// priority, then the backup's <c>--dbpath</c> advanced setting, then the stored
|
||||
/// <see cref="Serialization.Interface.IBackup.DBPath"/>. If <c>no-local-db</c> is set
|
||||
/// (either as an extra option or as a backup advanced setting), the result is <c>null</c>.
|
||||
/// </summary>
|
||||
/// <returns>The effective path, or <c>null</c></returns>
|
||||
public static string? GetEffectiveDBPath(IRunnerData? runnerData)
|
||||
{
|
||||
if (runnerData == null)
|
||||
return null;
|
||||
|
||||
if (runnerData.ExtraOptions != null)
|
||||
{
|
||||
if (runnerData.ExtraOptions.ContainsKey("no-local-db") && Utility.ParseBoolOption(runnerData.ExtraOptions.AsReadOnly(), "no-local-db"))
|
||||
return null;
|
||||
if (runnerData.ExtraOptions.TryGetValue("dbpath", out var dbpath) && !string.IsNullOrWhiteSpace(dbpath))
|
||||
return dbpath;
|
||||
}
|
||||
|
||||
if (runnerData.Backup == null)
|
||||
return null;
|
||||
|
||||
var setting = runnerData.Backup.Settings?.FirstOrDefault(s => s.Name.Equals($"--no-local-db", StringComparison.OrdinalIgnoreCase));
|
||||
if (setting != null && Utility.ParseBool(setting.Value, true))
|
||||
return null;
|
||||
|
||||
return GetEffectiveDBPath(runnerData.Backup);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the effective local database path for a backup: the "--dbpath" advanced option if
|
||||
/// it is set, otherwise the stored <see cref="Serialization.Interface.IBackup.DBPath"/>. This
|
||||
/// mirrors the precedence applied by <see cref="ApplyOptions"/> so that every operation agrees
|
||||
/// on which database file to use (see issue #1698).
|
||||
/// </summary>
|
||||
public static string GetEffectiveDBPath(Serialization.Interface.IBackup backup)
|
||||
{
|
||||
var dbpath = backup.Settings?
|
||||
.FirstOrDefault(s => s.Name.Equals("--dbpath", StringComparison.OrdinalIgnoreCase))?.Value;
|
||||
return string.IsNullOrWhiteSpace(dbpath) ? backup.DBPath : dbpath;
|
||||
}
|
||||
|
||||
internal static Dictionary<string, string?> ApplyOptions(Connection databaseConnection, Serialization.Interface.IBackup backup, Dictionary<string, string?> options, out string url)
|
||||
{
|
||||
url = backup.TargetURL;
|
||||
|
||||
@@ -41,6 +41,13 @@ namespace Duplicati.Library.SQLiteHelper
|
||||
/// </summary>
|
||||
private static readonly string LOGTAG = Logging.Log.LogTagFromType(typeof(SQLiteLoader));
|
||||
|
||||
/// <summary>
|
||||
/// The default busy timeout (in milliseconds) applied to every connection opened by
|
||||
/// <see cref="OpenSQLiteFileAsync"/> via the connection string. This is a defense-in-depth
|
||||
/// measure that ensures an active reader does not cause failed commits.
|
||||
/// </summary>
|
||||
private const int DefaultBusyTimeoutMs = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Helper method with logic to handle opening a database in possibly encrypted format.
|
||||
/// </summary>
|
||||
@@ -284,6 +291,12 @@ namespace Duplicati.Library.SQLiteHelper
|
||||
con.ConnectionString = $"Data Source={path};Pooling=false";
|
||||
await con.OpenAsync().ConfigureAwait(false);
|
||||
|
||||
using (var cmd = con.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"PRAGMA busy_timeout={DefaultBusyTimeoutMs}";
|
||||
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// A database file we create is always locked down. For a pre-existing file we skip
|
||||
// the lockdown when the operator has opted in to an insecure data folder, mirroring
|
||||
// the data-folder behavior (a deliberately shared folder must not have its contents
|
||||
@@ -295,7 +308,7 @@ namespace Duplicati.Library.SQLiteHelper
|
||||
return;
|
||||
|
||||
try { SystemIO.IO_OS.FileSetPermissionUserRWOnly(path); }
|
||||
catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "SQLiteFilePermissionError", ex, "Failed to set permissions on SQLite file '{0}'", path); }
|
||||
catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "SQLiteFilePermissionError", ex, "Failed to set permissions on SQLite file '{0}'", path); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -59,7 +59,10 @@ public class SslCertificateValidator(bool acceptAll, string[]? validHashes, bool
|
||||
|
||||
using var certificate = cert as X509Certificate2 ?? new X509Certificate2(cert ?? throw new ArgumentNullException(nameof(cert)));
|
||||
|
||||
// Validate date range before anything else, reject expired certs
|
||||
// Validate date range before anything else, reject expired certs.
|
||||
// NotBefore/NotAfter are returned by .NET as DateTime with Kind=Local (the UTC
|
||||
// instant expressed in local time), so DateTime.Now (also Kind=Local) is the
|
||||
// correct comparison; using UtcNow here would skew the window by the timezone offset.
|
||||
if (!IsDateValid(certificate, now))
|
||||
return false;
|
||||
|
||||
@@ -77,7 +80,11 @@ public class SslCertificateValidator(bool acceptAll, string[]? validHashes, bool
|
||||
}
|
||||
|
||||
|
||||
// If requested, ignore revocation check failures (e.g. OCSP server offline or status unknown)
|
||||
// If requested, ignore revocation check failures (e.g. OCSP server offline or status unknown).
|
||||
// This strips revocation-only flags from the sslPolicyErrors that the TLS stack reported using
|
||||
// its own chain object. The explicit chain built below operates on a separate X509Chain and
|
||||
// applies the same soft-fail logic independently, so both the reported-policy path and the
|
||||
// verify path honor ignoreRevocationFailure consistently.
|
||||
if (ignoreRevocationFailure)
|
||||
sslPolicyErrors = FilterRevocationErrors(sslPolicyErrors, chain);
|
||||
|
||||
@@ -85,7 +92,70 @@ public class SslCertificateValidator(bool acceptAll, string[]? validHashes, bool
|
||||
throw new InvalidCertificateException(certificate.GetCertHashString(), sslPolicyErrors);
|
||||
|
||||
// If no hash is found, perform the standard validations
|
||||
return sslPolicyErrors == SslPolicyErrors.None && certificate.Verify();
|
||||
if (sslPolicyErrors != SslPolicyErrors.None)
|
||||
return false;
|
||||
|
||||
// certificate.Verify() builds its own X509Chain with a default policy that
|
||||
// hard-fails when a revocation check cannot be completed (OCSP/CRL endpoint
|
||||
// unreachable). The OS-native TLS stacks instead soft-fail in that situation:
|
||||
// the certificate is accepted when the revocation status is merely unknown
|
||||
// (not confirmed revoked). Build the chain explicitly so we can replicate that
|
||||
// behavior and honor the ignoreRevocationFailure setting.
|
||||
//
|
||||
// Note: ChainPolicy.TrustMode is left at its default (UseSystemDefault), which
|
||||
// resolves intermediate/root trust through the OS trust store exactly as
|
||||
// certificate.Verify() did, so the trust-root behaviour of the previous
|
||||
// implementation is preserved.
|
||||
using var verifyChain = new X509Chain();
|
||||
// The primary mechanism for honoring ignoreRevocationFailure: skip the online
|
||||
// revocation fetch entirely so an unreachable OCSP/CRL endpoint cannot fail the
|
||||
// chain. The post-build soft-fail below is a safety net for platforms that
|
||||
// still surface revocation status flags even under NoCheck, not the main path.
|
||||
verifyChain.ChainPolicy.RevocationMode =
|
||||
ignoreRevocationFailure ? X509RevocationMode.NoCheck : X509RevocationMode.Online;
|
||||
bool chainValid = verifyChain.Build(certificate);
|
||||
|
||||
// Soft-fail on unreachable revocation checks, but only when the caller has
|
||||
// explicitly requested it via ignoreRevocationFailure. This replicates the
|
||||
// OS-native TLS behavior of accepting a certificate whose revocation status is
|
||||
// merely unknown (OCSP/CRL endpoint unreachable) rather than confirmed revoked.
|
||||
//
|
||||
// This block is a safety net for the NoCheck mode above: on some platforms
|
||||
// Build() can still report revocation-related flags despite RevocationMode being
|
||||
// NoCheck, so strip those flags here rather than relying on NoCheck alone.
|
||||
//
|
||||
// The guard requires a non-empty ChainStatus: if Build() returned false with no
|
||||
// status flags (rare platform edge case), we must not silently accept the
|
||||
// certificate, as that would widen trust beyond what any status indicates.
|
||||
// A confirmed Revoked flag, PartialChain, or any other non-revocation status
|
||||
// still fails the chain because the All() predicate would be false. The using
|
||||
// scope ensures verifyChain is disposed even if Build() throws and the outer
|
||||
// catch wraps the exception.
|
||||
if (!chainValid && ignoreRevocationFailure && verifyChain.ChainStatus.Length > 0 &&
|
||||
verifyChain.ChainStatus.All(s => (s.Status & ~RevocationFailureFlags) == 0))
|
||||
chainValid = true;
|
||||
|
||||
if (!chainValid)
|
||||
{
|
||||
var chainStatus = string.Join("; ", verifyChain.ChainStatus.Select(s => $"{s.Status}={s.StatusInformation?.Trim()}"));
|
||||
var elementDetails = verifyChain.ChainElements.Count == 0
|
||||
? "(no chain elements)"
|
||||
: string.Join(" | ", verifyChain.ChainElements.Select(e =>
|
||||
{
|
||||
var elemStatus = e.ChainElementStatus.Length == 0
|
||||
? "(none)"
|
||||
: string.Join(", ", e.ChainElementStatus.Select(s => $"{s.Status}={s.StatusInformation?.Trim()}"));
|
||||
return $"{e.Certificate.Subject} [{elemStatus}]";
|
||||
}));
|
||||
|
||||
Duplicati.Library.Logging.Log.WriteWarningMessage(
|
||||
"SslCertificateValidator",
|
||||
"VerifyChainFailed",
|
||||
null,
|
||||
$"Certificate chain validation failed for {certificate.Subject} (issuer={certificate.Issuer}, " +
|
||||
$"thumbprint={certificate.Thumbprint}). Chain status: {chainStatus}. Per-element: {elementDetails}");
|
||||
}
|
||||
return chainValid;
|
||||
}
|
||||
catch (InvalidCertificateException)
|
||||
{
|
||||
|
||||
@@ -44,6 +44,13 @@ namespace Duplicati.Library.Utility
|
||||
private static readonly object m_lock = new object();
|
||||
private static readonly Dictionary<string, System.Diagnostics.StackTrace> m_fileTrace = new Dictionary<string, System.Diagnostics.StackTrace>();
|
||||
|
||||
// Process-wide monotonic counter appended to each generated name to guarantee
|
||||
// uniqueness. The timestamp is only second-precision and the Guid is truncated to
|
||||
// 8 characters, so under load (many temp files created in the same second by the
|
||||
// same caller) those alone can collide, which would produce duplicate file paths
|
||||
// and throw from the m_fileTrace.Add below.
|
||||
private static long m_uniqueCounter;
|
||||
|
||||
public static System.Diagnostics.StackTrace GetStackTraceForTempFile(string filename)
|
||||
{
|
||||
lock (m_lock)
|
||||
@@ -53,17 +60,20 @@ namespace Duplicati.Library.Utility
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GenerateUniqueName()
|
||||
internal static string GenerateUniqueName()
|
||||
{
|
||||
// The trailing sequence number guarantees a unique name even when the caller,
|
||||
// second-precision timestamp, and truncated Guid all coincide.
|
||||
var seq = System.Threading.Interlocked.Increment(ref m_uniqueCounter);
|
||||
var st = new System.Diagnostics.StackTrace();
|
||||
foreach (var f in st.GetFrames())
|
||||
{
|
||||
var asm = f.GetMethod()?.DeclaringType?.Assembly;
|
||||
if (asm != null && asm != typeof(TempFile).Assembly)
|
||||
{
|
||||
var n = string.Format("{0}_{1}_{2}_{3}", f.GetMethod().DeclaringType.FullName, f.GetMethod().Name, Library.Utility.Utility.SerializeDateTime(DateTime.UtcNow), Guid.NewGuid().ToString().Substring(0, 8));
|
||||
var n = string.Format("{0}_{1}_{2}_{3}_{4}", f.GetMethod().DeclaringType.FullName, f.GetMethod().Name, Library.Utility.Utility.SerializeDateTime(DateTime.UtcNow), Guid.NewGuid().ToString().Substring(0, 8), seq);
|
||||
if (n.IndexOfAny(System.IO.Path.GetInvalidFileNameChars()) >= 0)
|
||||
n = string.Format("{0}_{1}_{2}_{3}", f.GetMethod().DeclaringType.Name, f.GetMethod().Name, Library.Utility.Utility.SerializeDateTime(DateTime.UtcNow), Guid.NewGuid().ToString().Substring(0, 8));
|
||||
n = string.Format("{0}_{1}_{2}_{3}_{4}", f.GetMethod().DeclaringType.Name, f.GetMethod().Name, Library.Utility.Utility.SerializeDateTime(DateTime.UtcNow), Guid.NewGuid().ToString().Substring(0, 8), seq);
|
||||
if (n.IndexOfAny(System.IO.Path.GetInvalidFileNameChars()) < 0)
|
||||
{
|
||||
lock (m_lock)
|
||||
@@ -73,13 +83,13 @@ namespace Duplicati.Library.Utility
|
||||
}
|
||||
}
|
||||
|
||||
var s = Guid.NewGuid().ToString();
|
||||
var s = string.Format("{0}_{1}", Guid.NewGuid(), seq);
|
||||
lock (m_lock)
|
||||
m_fileTrace.Add(s, st);
|
||||
return s;
|
||||
}
|
||||
#else
|
||||
private static string GenerateUniqueName()
|
||||
internal static string GenerateUniqueName()
|
||||
{
|
||||
return APPLICATION_PREFIX + Guid.NewGuid().ToString();
|
||||
}
|
||||
|
||||
@@ -3,16 +3,12 @@ backupApp.directive('waitArea', function() {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
taskid: '=taskid',
|
||||
text: '=text',
|
||||
allowCancel: '=allowCancel'
|
||||
text: '=text'
|
||||
},
|
||||
templateUrl: 'templates/waitarea.html',
|
||||
controller: function($scope, ServerStatus, AppService) {
|
||||
controller: function($scope, ServerStatus) {
|
||||
$scope.ServerStatus = ServerStatus;
|
||||
$scope.serverstate = ServerStatus.watch($scope);
|
||||
$scope.cancelTask = function() {
|
||||
AppService.post('/task/' + $scope.taskid + '/stop');
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
</div>
|
||||
|
||||
<div ng-show="connecting == true && taskid != null">
|
||||
<wait-area taskid="taskid" text="ConnectionProgress" allow-cancel="true"></wait-area>
|
||||
<wait-area taskid="taskid" text="ConnectionProgress"></wait-area>
|
||||
</div>
|
||||
|
||||
<div ng-show="connecting == true && taskid == null">
|
||||
|
||||
@@ -15,8 +15,4 @@
|
||||
{{'Server is currently paused,' | translate}} <a style="cursor: pointer" ng-click="ServerStatus.resume()" translate>resume now</a>
|
||||
</div>
|
||||
|
||||
<div ng-show="allowCancel">
|
||||
<a href class="button" ng-click="cancelTask()" translate>Cancel</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -193,8 +193,8 @@ namespace Duplicati.UnitTest
|
||||
opts["blocksize"] = "10kb";
|
||||
opts["backup-test-samples"] = "0";
|
||||
opts["unittest-mode"] = "true";
|
||||
if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux())
|
||||
opts["snapshot-policy"] = "Off";
|
||||
opts["allow-sleep"] = "true";
|
||||
opts["snapshot-policy"] = "Off";
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
// 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.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.WebserverCore.Services;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
namespace Duplicati.UnitTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DatabaseLockTracker"/>, which coordinates access to local
|
||||
/// backup database files between queued and immediate operations.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class DatabaseLockTrackerTests
|
||||
{
|
||||
/// <summary>
|
||||
/// A simple value-returning async disposable used in tests.
|
||||
/// </summary>
|
||||
private static async Task DisposeAsync(IAsyncDisposable disposable)
|
||||
=> await disposable.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
[Test]
|
||||
public async Task TryAcquireReturnsLockWhenFree()
|
||||
{
|
||||
var tracker = new DatabaseLockTracker();
|
||||
var handle = tracker.TryAcquire("/tmp/test.sqlite");
|
||||
Assert.IsNotNull(handle, "TryAcquire should succeed when the database is not locked");
|
||||
await DisposeAsync(handle!);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TryAcquireReturnsNullWhenAlreadyLocked()
|
||||
{
|
||||
var tracker = new DatabaseLockTracker();
|
||||
var first = tracker.TryAcquire("/tmp/test.sqlite");
|
||||
Assert.IsNotNull(first, "First TryAcquire should succeed");
|
||||
|
||||
var second = tracker.TryAcquire("/tmp/test.sqlite");
|
||||
Assert.IsNull(second, "Second TryAcquire should fail when the database is locked");
|
||||
|
||||
await DisposeAsync(first!);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AcquireAsyncWaitsForExistingLock()
|
||||
{
|
||||
var tracker = new DatabaseLockTracker();
|
||||
var first = tracker.TryAcquire("/tmp/test.sqlite");
|
||||
Assert.IsNotNull(first);
|
||||
|
||||
var acquireTask = tracker.AcquireAsync("/tmp/test.sqlite", CancellationToken.None);
|
||||
Assert.IsFalse(acquireTask.IsCompleted, "AcquireAsync should be waiting for the lock");
|
||||
|
||||
await DisposeAsync(first!);
|
||||
var second = await acquireTask;
|
||||
Assert.IsNotNull(second, "AcquireAsync should complete after the lock is released");
|
||||
await DisposeAsync(second);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReleaseAllowsSubsequentAcquire()
|
||||
{
|
||||
var tracker = new DatabaseLockTracker();
|
||||
var first = tracker.TryAcquire("/tmp/test.sqlite");
|
||||
Assert.IsNotNull(first);
|
||||
await DisposeAsync(first!);
|
||||
|
||||
var second = tracker.TryAcquire("/tmp/test.sqlite");
|
||||
Assert.IsNotNull(second, "TryAcquire should succeed after the lock is released");
|
||||
await DisposeAsync(second!);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DifferentPathsAreIndependent()
|
||||
{
|
||||
var tracker = new DatabaseLockTracker();
|
||||
var lockA = tracker.TryAcquire("/tmp/a.sqlite");
|
||||
Assert.IsNotNull(lockA);
|
||||
|
||||
var lockB = tracker.TryAcquire("/tmp/b.sqlite");
|
||||
Assert.IsNotNull(lockB, "TryAcquire on a different path should succeed independently");
|
||||
|
||||
await DisposeAsync(lockA!);
|
||||
await DisposeAsync(lockB!);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AcquireAsyncCancelsWhenTokenFires()
|
||||
{
|
||||
var tracker = new DatabaseLockTracker();
|
||||
var first = tracker.TryAcquire("/tmp/test.sqlite");
|
||||
Assert.IsNotNull(first);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
var acquireTask = tracker.AcquireAsync("/tmp/test.sqlite", cts.Token);
|
||||
Assert.IsFalse(acquireTask.IsCompleted);
|
||||
|
||||
cts.Cancel();
|
||||
|
||||
Assert.ThrowsAsync<System.OperationCanceledException>(async () => await acquireTask);
|
||||
|
||||
await DisposeAsync(first!);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DoubleDisposeIsSafe()
|
||||
{
|
||||
var tracker = new DatabaseLockTracker();
|
||||
var handle = tracker.TryAcquire("/tmp/test.sqlite");
|
||||
Assert.IsNotNull(handle);
|
||||
|
||||
await DisposeAsync(handle!);
|
||||
await DisposeAsync(handle!);
|
||||
|
||||
var next = tracker.TryAcquire("/tmp/test.sqlite");
|
||||
Assert.IsNotNull(next, "After double-dispose, the lock should be releasable exactly once");
|
||||
await DisposeAsync(next!);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TryAcquireOnLockedReleasesCorrectly()
|
||||
{
|
||||
var tracker = new DatabaseLockTracker();
|
||||
var first = tracker.TryAcquire("/tmp/test.sqlite");
|
||||
Assert.IsNotNull(first);
|
||||
|
||||
var second = tracker.TryAcquire("/tmp/test.sqlite");
|
||||
Assert.IsNull(second);
|
||||
|
||||
await DisposeAsync(first!);
|
||||
|
||||
var third = tracker.TryAcquire("/tmp/test.sqlite");
|
||||
Assert.IsNotNull(third, "After releasing the first lock, TryAcquire should succeed");
|
||||
await DisposeAsync(third!);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RelativeAndAbsolutePathMapToSameLock()
|
||||
{
|
||||
var tracker = new DatabaseLockTracker();
|
||||
var absolute = tracker.TryAcquire(System.IO.Path.GetFullPath("/tmp/test.sqlite"));
|
||||
Assert.IsNotNull(absolute);
|
||||
|
||||
var relative = tracker.TryAcquire("/tmp/test.sqlite");
|
||||
Assert.IsNull(relative, "Path normalization should map relative and absolute paths to the same lock");
|
||||
|
||||
await DisposeAsync(absolute!);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AcquireAsyncPreservesOrder()
|
||||
{
|
||||
var tracker = new DatabaseLockTracker();
|
||||
var first = tracker.TryAcquire("/tmp/test.sqlite");
|
||||
Assert.IsNotNull(first);
|
||||
|
||||
var order = new System.Collections.Generic.List<int>();
|
||||
|
||||
var t2 = Task.Run(async () =>
|
||||
{
|
||||
await using var h = await tracker.AcquireAsync("/tmp/test.sqlite", CancellationToken.None);
|
||||
order.Add(2);
|
||||
});
|
||||
|
||||
var t3 = Task.Run(async () =>
|
||||
{
|
||||
await using var h = await tracker.AcquireAsync("/tmp/test.sqlite", CancellationToken.None);
|
||||
order.Add(3);
|
||||
});
|
||||
|
||||
await Task.Delay(50);
|
||||
order.Add(1);
|
||||
await DisposeAsync(first!);
|
||||
|
||||
await Task.WhenAll(t2, t3);
|
||||
|
||||
Assert.AreEqual(1, order[0], "First lock holder should complete first");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryAcquireEmptyPathThrows()
|
||||
{
|
||||
var tracker = new DatabaseLockTracker();
|
||||
Assert.Throws<ArgumentException>(() => tracker.TryAcquire(""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryAcquireWhitespacePathThrows()
|
||||
{
|
||||
var tracker = new DatabaseLockTracker();
|
||||
Assert.Throws<ArgumentException>(() => tracker.TryAcquire(" "));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AcquireAsyncEmptyPathThrows()
|
||||
{
|
||||
var tracker = new DatabaseLockTracker();
|
||||
Assert.ThrowsAsync<ArgumentException>(async () => await tracker.AcquireAsync("", CancellationToken.None));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ public class FolderStatusServiceTests
|
||||
public void Resume() { }
|
||||
public void Pause() { }
|
||||
public IList<Tuple<long, string?>> GetQueueWithIds() => new List<Tuple<long, string?>>();
|
||||
public void CancelCurrentTaskLockWait(long taskID) { }
|
||||
public Task<IBasicResults?> RunImmediatelyAsync(IQueuedTask task) => Task.FromResult<IBasicResults?>(null);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// 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.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Server.Database;
|
||||
using Duplicati.Server.Serialization.Interface;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
namespace Duplicati.UnitTest
|
||||
{
|
||||
/// <summary>
|
||||
/// https://github.com/duplicati/duplicati/issues/1698
|
||||
/// "Show log" and "Database delete" read/deleted the stored <c>Backup.DBPath</c> directly, while
|
||||
/// most operations honor a "--dbpath" advanced option. When those disagreed, Show log opened the
|
||||
/// wrong/empty database ("no such table: LogData"). <see cref="Duplicati.Server.Runner.GetEffectiveDBPath"/>
|
||||
/// reconciles them with the same precedence as the runner; these tests cover that precedence.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class Issue1698
|
||||
{
|
||||
private static Backup CreateBackup(string? storedDbPath, params (string Name, string Value)[] settings)
|
||||
{
|
||||
var backup = new Backup
|
||||
{
|
||||
ID = null,
|
||||
Name = "Test Backup",
|
||||
Description = "",
|
||||
Tags = new string[0],
|
||||
TargetURL = "file:///test",
|
||||
Sources = new string[0],
|
||||
Settings = settings.Select(s => (ISetting)new Setting { Name = s.Name, Value = s.Value }).ToArray(),
|
||||
Filters = new IFilter[0],
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
if (storedDbPath != null)
|
||||
backup.SetDBPath(storedDbPath);
|
||||
return backup;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UsesStoredDbPathWhenNoAdvancedOption()
|
||||
{
|
||||
var backup = CreateBackup("/stored/path.sqlite");
|
||||
Assert.AreEqual("/stored/path.sqlite", Duplicati.Server.Runner.GetEffectiveDBPath(backup));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AdvancedDbPathOptionOverridesStoredDbPath()
|
||||
{
|
||||
var backup = CreateBackup("/stored/path.sqlite", ("--dbpath", "/override/path.sqlite"));
|
||||
Assert.AreEqual("/override/path.sqlite", Duplicati.Server.Runner.GetEffectiveDBPath(backup));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AdvancedDbPathMatchIsCaseInsensitiveOnName()
|
||||
{
|
||||
var backup = CreateBackup("/stored/path.sqlite", ("--DBPath", "/override/path.sqlite"));
|
||||
Assert.AreEqual("/override/path.sqlite", Duplicati.Server.Runner.GetEffectiveDBPath(backup));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BlankAdvancedDbPathFallsBackToStoredDbPath()
|
||||
{
|
||||
var backup = CreateBackup("/stored/path.sqlite", ("--dbpath", " "));
|
||||
Assert.AreEqual("/stored/path.sqlite", Duplicati.Server.Runner.GetEffectiveDBPath(backup));
|
||||
}
|
||||
|
||||
// ---- Tests for GetEffectiveDBPath(IQueuedTask) / GetEffectiveDBPath(IRunnerData) ----
|
||||
|
||||
private static Duplicati.Server.Runner.IRunnerData CreateRunnerTask(
|
||||
Backup backup,
|
||||
IDictionary<string, string?>? extraOptions = null)
|
||||
=> Duplicati.Server.Runner.CreateTask(
|
||||
Duplicati.Server.Serialization.DuplicatiOperation.BackupOrSync,
|
||||
backup,
|
||||
extraOptions ?? new Dictionary<string, string?>());
|
||||
|
||||
[Test]
|
||||
public void RunnerDataFallsBackToBackupDbPath()
|
||||
{
|
||||
var backup = CreateBackup("/stored/path.sqlite");
|
||||
var task = CreateRunnerTask(backup);
|
||||
Assert.AreEqual("/stored/path.sqlite", Duplicati.Server.Runner.GetEffectiveDBPath(task));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RunnerDataRespectsBackupAdvancedDbPath()
|
||||
{
|
||||
var backup = CreateBackup("/stored/path.sqlite", ("--dbpath", "/override/path.sqlite"));
|
||||
var task = CreateRunnerTask(backup);
|
||||
Assert.AreEqual("/override/path.sqlite", Duplicati.Server.Runner.GetEffectiveDBPath(task));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RunnerDataExtraDbPathOverridesBackup()
|
||||
{
|
||||
var backup = CreateBackup("/stored/path.sqlite", ("--dbpath", "/backup-override.sqlite"));
|
||||
var task = CreateRunnerTask(backup, new Dictionary<string, string?> { ["dbpath"] = "/extra-override.sqlite" });
|
||||
Assert.AreEqual("/extra-override.sqlite", Duplicati.Server.Runner.GetEffectiveDBPath(task));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RunnerDataExtraNoLocalDbReturnsNull()
|
||||
{
|
||||
var backup = CreateBackup("/stored/path.sqlite");
|
||||
var task = CreateRunnerTask(backup, new Dictionary<string, string?> { ["no-local-db"] = "true" });
|
||||
Assert.IsNull(Duplicati.Server.Runner.GetEffectiveDBPath(task));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RunnerDataBackupNoLocalDbSettingReturnsNull()
|
||||
{
|
||||
var backup = CreateBackup("/stored/path.sqlite", ("--no-local-db", "true"));
|
||||
var task = CreateRunnerTask(backup);
|
||||
Assert.IsNull(Duplicati.Server.Runner.GetEffectiveDBPath(task));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RunnerDataExtraNoLocalDbFalseReturnsDbPath()
|
||||
{
|
||||
var backup = CreateBackup("/stored/path.sqlite");
|
||||
var task = CreateRunnerTask(backup, new Dictionary<string, string?> { ["no-local-db"] = "false" });
|
||||
Assert.AreEqual("/stored/path.sqlite", Duplicati.Server.Runner.GetEffectiveDBPath(task));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RunnerDataNullReturnsNull()
|
||||
{
|
||||
Assert.IsNull(Duplicati.Server.Runner.GetEffectiveDBPath((Duplicati.Server.Runner.IRunnerData?)null));
|
||||
Assert.IsNull(Duplicati.Server.Runner.GetEffectiveDBPath((Duplicati.Server.Serialization.Interface.IQueuedTask?)null));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RunnerDataNonRunnerDataQueuedTaskReturnsNull()
|
||||
{
|
||||
// A non-IRunnerData IQueuedTask implementation should cast to null and return null.
|
||||
var nonRunnerTask = new NonRunnerQueuedTask();
|
||||
Assert.IsNull(Duplicati.Server.Runner.GetEffectiveDBPath(nonRunnerTask));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A minimal <see cref="IQueuedTask"/> implementation that is NOT an
|
||||
/// <see cref="Duplicati.Server.Runner.IRunnerData"/>, used to verify that
|
||||
/// <see cref="Duplicati.Server.Runner.GetEffectiveDBPath(IQueuedTask)"/> safely returns
|
||||
/// null for non-runner tasks.
|
||||
/// </summary>
|
||||
private sealed class NonRunnerQueuedTask : Duplicati.Server.Serialization.Interface.IQueuedTask
|
||||
{
|
||||
public long TaskID => 0;
|
||||
public string? BackupID => null;
|
||||
public Duplicati.Server.Serialization.DuplicatiOperation Operation => Duplicati.Server.Serialization.DuplicatiOperation.BackupOrSync;
|
||||
public Func<Task>? OnStarting { get; set; }
|
||||
public Func<Exception?, Task>? OnFinished { get; set; }
|
||||
public DateTime? TaskStarted { get; set; }
|
||||
public DateTime? TaskFinished { get; set; }
|
||||
public Task UpdateThrottleSpeedsAsync(string? uploadSpeed, string? downloadSpeed) => Task.CompletedTask;
|
||||
public Task StopAsync() => Task.CompletedTask;
|
||||
public Task AbortAsync() => Task.CompletedTask;
|
||||
public Task PauseAsync(bool alsoTransfers) => Task.CompletedTask;
|
||||
public Task ResumeAsync() => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// 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.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
namespace Duplicati.UnitTest
|
||||
{
|
||||
public class Issue2287 : BasicSetupHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// https://github.com/duplicati/duplicati/issues/2287
|
||||
/// The "find" command must locate a file in ANY backup version, not only the newest one.
|
||||
/// A bare filename is turned into a wildcard filter (e.g. "*/target.txt"), which previously
|
||||
/// searched only the newest version; the all-versions fallback only triggered when the newest
|
||||
/// version had zero matches, so a file present only in an older version was silently missed
|
||||
/// whenever the newest version still contained a same-named file. "find" now searches all
|
||||
/// versions by default, while "list" keeps its newest-only default.
|
||||
/// </summary>
|
||||
[Test]
|
||||
[Category("Targeted")]
|
||||
public async Task FindSearchesAllVersionsAsync()
|
||||
{
|
||||
var testopts = TestOptions.Expand(new { no_encryption = true });
|
||||
|
||||
var subA = Path.Combine(DATAFOLDER, "sub_a");
|
||||
var subB = Path.Combine(DATAFOLDER, "sub_b");
|
||||
Directory.CreateDirectory(subA);
|
||||
Directory.CreateDirectory(subB);
|
||||
var fileA = Path.Combine(subA, "target.txt");
|
||||
var fileB = Path.Combine(subB, "target.txt");
|
||||
File.WriteAllText(fileA, "present in newest and old version");
|
||||
File.WriteAllText(fileB, "present only in the old version");
|
||||
|
||||
// Version 1: both sub_a/target.txt and sub_b/target.txt exist.
|
||||
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
||||
{
|
||||
var res = await c.BackupAsync(new[] { DATAFOLDER });
|
||||
Assert.AreEqual(0, res.Errors.Count());
|
||||
}
|
||||
|
||||
System.Threading.Thread.Sleep(TimeSpan.FromSeconds(3));
|
||||
|
||||
// Version 0 (newest): sub_b/target.txt is removed, so it now exists only in the old version.
|
||||
File.Delete(fileB);
|
||||
Directory.Delete(subB);
|
||||
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
||||
{
|
||||
var res = await c.BackupAsync(new[] { DATAFOLDER });
|
||||
Assert.AreEqual(0, res.Errors.Count());
|
||||
}
|
||||
|
||||
// "find target.txt" must surface the older-only sub_b/target.txt as well as the newest one.
|
||||
var findOutput = RunListCommand(Duplicati.CommandLine.Commands.Find, "target.txt", testopts);
|
||||
Assert.IsTrue(findOutput.Contains("sub_a"), "find should list the newest match:" + Environment.NewLine + findOutput);
|
||||
Assert.IsTrue(findOutput.Contains("sub_b"), "find should also list the older-only match:" + Environment.NewLine + findOutput);
|
||||
|
||||
// "list target.txt" keeps the newest-only behaviour, so it must NOT include the older-only file.
|
||||
var listOutput = RunListCommand(Duplicati.CommandLine.Commands.List, "target.txt", testopts);
|
||||
Assert.IsTrue(listOutput.Contains("sub_a"), "list should list the newest match:" + Environment.NewLine + listOutput);
|
||||
Assert.IsFalse(listOutput.Contains("sub_b"), "list should not list the older-only match:" + Environment.NewLine + listOutput);
|
||||
}
|
||||
|
||||
private string RunListCommand(
|
||||
Func<TextWriter, Action<Library.Main.Controller>, List<string>, Dictionary<string, string>, Library.Utility.IFilter, int> command,
|
||||
string filename,
|
||||
Dictionary<string, string> baseopts)
|
||||
{
|
||||
using var sw = new StringWriter();
|
||||
// The list/find commands mutate the args and options, so hand each invocation its own copies.
|
||||
var args = new List<string> { "file://" + TARGETFOLDER, filename };
|
||||
var opts = new Dictionary<string, string>(baseopts);
|
||||
command(sw, _ => { }, args, opts, null);
|
||||
return sw.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
namespace Duplicati.UnitTest
|
||||
{
|
||||
public class Issue4812 : BasicSetupHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// https://github.com/duplicati/duplicati/issues/4812
|
||||
/// `test-filters --parameters-file=...` must test the --source paths listed in the
|
||||
/// parameters file. Previously the parameters-file handling only appended --source for the
|
||||
/// `backup` command and always injected --target as the first positional argument, so for
|
||||
/// test-filters the --source entries were dropped and the --target was tested as the (bogus)
|
||||
/// source path — matching zero files.
|
||||
/// </summary>
|
||||
[Test]
|
||||
[Category("Targeted")]
|
||||
public void TestFiltersUsesSourceFromParametersFile()
|
||||
{
|
||||
// A source folder with a known number of plain files.
|
||||
var sourceFolder = Path.Combine(DATAFOLDER, "src");
|
||||
Directory.CreateDirectory(sourceFolder);
|
||||
const int fileCount = 3;
|
||||
for (var i = 0; i < fileCount; i++)
|
||||
File.WriteAllText(Path.Combine(sourceFolder, $"file{i}.txt"), "content " + i);
|
||||
|
||||
// A shared-style parameters file carrying both --target (used by backup) and --source.
|
||||
var paramFile = Path.Combine(DATAFOLDER, "params.txt");
|
||||
File.WriteAllLines(paramFile, new[]
|
||||
{
|
||||
"--source=" + sourceFolder,
|
||||
"--target=file://" + TARGETFOLDER
|
||||
});
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var err = new StringBuilder();
|
||||
int exit;
|
||||
using (var sw = new StringWriter(sb))
|
||||
using (var ew = new StringWriter(err))
|
||||
exit = Duplicati.CommandLine.Program.RunCommandLine(sw, ew, _ => { }, new[]
|
||||
{
|
||||
"test-filters",
|
||||
"--parameters-file=" + paramFile
|
||||
});
|
||||
|
||||
var output = sb.ToString();
|
||||
Assert.AreEqual(0, exit, "test-filters should succeed" + Environment.NewLine + output + Environment.NewLine + err);
|
||||
|
||||
var m = Regex.Match(output, @"Matched (\d+) files");
|
||||
Assert.IsTrue(m.Success, "Expected a 'Matched N files' summary" + Environment.NewLine + output);
|
||||
|
||||
var matched = int.Parse(m.Groups[1].Value);
|
||||
// The --source folder's files must have been tested. Before the fix this was 0, because
|
||||
// --target was injected as the tested path and --source was ignored.
|
||||
Assert.AreEqual(fileCount, matched, "test-filters should have matched the --source files" + Environment.NewLine + output);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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.Threading.Tasks;
|
||||
using Duplicati.Library.Main.Database;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Duplicati.UnitTest;
|
||||
|
||||
public class Issue5827
|
||||
{
|
||||
[Test]
|
||||
public async Task DisposeDoesNotThrowWhenTransactionAlreadyCompletedAsync()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
|
||||
var transaction = (SqliteTransaction)await connection.BeginTransactionAsync();
|
||||
await using var reusableTransaction = new ReusableTransaction(connection, transaction);
|
||||
|
||||
await transaction.CommitAsync();
|
||||
|
||||
Assert.DoesNotThrowAsync(async () => await reusableTransaction.DisposeAsync().AsTask());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// 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.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Library.Main;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
namespace Duplicati.UnitTest
|
||||
{
|
||||
public class Issue6205 : BasicSetupHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// https://github.com/duplicati/duplicati/issues/6205
|
||||
/// If all files are deleted from the remote destination and the user then runs repair
|
||||
/// (which recreates the local database), the recreate fails because the destination is
|
||||
/// empty. Previously the partial/empty database that was created was left on disk, which
|
||||
/// blocked every subsequent operation — a fresh backup could not run, and even retrying
|
||||
/// the repair failed because the recreate refuses to run when the database file exists.
|
||||
/// The failed recreate must remove the incomplete database so a fresh backup can proceed.
|
||||
/// </summary>
|
||||
[Test]
|
||||
[Category("Targeted")]
|
||||
public async Task RepairOnEmptyDestinationDoesNotLeavePartialDatabase()
|
||||
{
|
||||
var options = new Dictionary<string, string>(this.TestOptions)
|
||||
{
|
||||
["no-encryption"] = "true"
|
||||
};
|
||||
|
||||
File.WriteAllText(Path.Combine(this.DATAFOLDER, "a.txt"), "hello");
|
||||
File.WriteAllText(Path.Combine(this.DATAFOLDER, "b.txt"), "world");
|
||||
|
||||
// Initial backup: creates the remote volumes and the local database.
|
||||
using (var c = new Controller("file://" + this.TARGETFOLDER, options, null))
|
||||
{
|
||||
var backupResults = await c.BackupAsync([this.DATAFOLDER]);
|
||||
Assert.AreEqual(0, backupResults.Errors.Count());
|
||||
}
|
||||
Assert.IsTrue(File.Exists(options["dbpath"]), "The local database should exist after a backup");
|
||||
|
||||
// Simulate "all files deleted on the remote": empty the destination and drop the
|
||||
// local database so the next repair goes through the recreate-from-remote path.
|
||||
File.Delete(options["dbpath"]);
|
||||
foreach (var f in Directory.GetFiles(this.TARGETFOLDER))
|
||||
File.Delete(f);
|
||||
|
||||
// Repair now has to recreate the database from an empty destination, which fails.
|
||||
Exception repairFailure = null;
|
||||
using (var c = new Controller("file://" + this.TARGETFOLDER, options, null))
|
||||
{
|
||||
try
|
||||
{
|
||||
await c.RepairAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
repairFailure = ex;
|
||||
}
|
||||
}
|
||||
Assert.IsNotNull(repairFailure, "Repair against an empty destination is expected to fail");
|
||||
|
||||
// The incomplete recreate database must NOT be left behind (this is the fix).
|
||||
Assert.IsFalse(File.Exists(options["dbpath"]),
|
||||
"The incomplete recreate database should have been removed after the failed repair");
|
||||
|
||||
// With the leftover database gone, a fresh backup must be able to run again.
|
||||
using (var c = new Controller("file://" + this.TARGETFOLDER, options, null))
|
||||
{
|
||||
var backupResults = await c.BackupAsync([this.DATAFOLDER]);
|
||||
Assert.AreEqual(0, backupResults.Errors.Count(),
|
||||
"A fresh backup should succeed after the failed repair cleaned up the partial database");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// 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.IO;
|
||||
using System.Linq;
|
||||
using Duplicati.Library.Logging;
|
||||
using Duplicati.Library.Main.Operation.Backup;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
namespace Duplicati.UnitTest
|
||||
{
|
||||
/// <summary>
|
||||
/// https://github.com/duplicati/duplicati/issues/6426
|
||||
/// Warnings for known, expected path problems (permission denied, locked file, path not
|
||||
/// found, path too long) must not carry the exception, so their (large) stack traces do not
|
||||
/// bloat the log. Genuinely unexpected errors still keep the exception for debugging.
|
||||
/// </summary>
|
||||
public class Issue6426
|
||||
{
|
||||
private static List<LogEntry> CaptureWarnings(Exception ex, string callerId)
|
||||
{
|
||||
var captured = new List<LogEntry>();
|
||||
using (Log.StartScope(e => captured.Add(e)))
|
||||
LogExceptionHelper.LogCommonWarning(ex, "TestTag", callerId, "/some/path");
|
||||
return captured.Where(e => e.Level == LogMessageType.Warning).ToList();
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Targeted")]
|
||||
public void KnownPathWarningsCarryNoException()
|
||||
{
|
||||
// These exception types are recognised as "known problems" on every platform.
|
||||
var cases = new (Exception Exception, string ExpectedId)[]
|
||||
{
|
||||
(new UnauthorizedAccessException("denied"), "PermissionDenied"),
|
||||
(new FileNotFoundException("missing"), "PathNotFound"),
|
||||
(new PathTooLongException("too long"), "PathTooLong"),
|
||||
};
|
||||
|
||||
foreach (var c in cases)
|
||||
{
|
||||
var warnings = CaptureWarnings(c.Exception, "SomeCallerId");
|
||||
Assert.AreEqual(1, warnings.Count, "Expected exactly one warning for " + c.ExpectedId);
|
||||
Assert.AreEqual(c.ExpectedId, warnings[0].Id, "Unexpected warning id");
|
||||
Assert.IsNull(warnings[0].Exception,
|
||||
c.ExpectedId + " is a known problem and must not attach the exception (no stack trace)");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Targeted")]
|
||||
public void UnknownPathWarningKeepsExceptionForDebugging()
|
||||
{
|
||||
var ex = new InvalidOperationException("unexpected");
|
||||
var warnings = CaptureWarnings(ex, "CustomId");
|
||||
Assert.AreEqual(1, warnings.Count);
|
||||
Assert.AreEqual("CustomId", warnings[0].Id, "Unknown errors should use the caller-supplied id");
|
||||
Assert.AreSame(ex, warnings[0].Exception, "Unknown errors must keep the exception for debugging");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// 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.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Library.Utility;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
namespace Duplicati.UnitTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Regression tests for <see cref="TempFile"/> unique-name generation. In DEBUG builds
|
||||
/// the name is built from the caller, a second-precision timestamp and a truncated Guid,
|
||||
/// which could collide when many temp files are created in the same second by the same
|
||||
/// caller — producing duplicate paths and throwing from the internal tracking dictionary.
|
||||
/// The generator now appends a process-wide counter to guarantee uniqueness.
|
||||
/// </summary>
|
||||
public class TempFileUniqueNameTests
|
||||
{
|
||||
[Test]
|
||||
[Category("Utility")]
|
||||
public void GeneratedNamesAreUniqueUnderConcurrentLoad()
|
||||
{
|
||||
const int threads = 8;
|
||||
const int perThread = 12500;
|
||||
const int expected = threads * perThread;
|
||||
|
||||
var names = new ConcurrentBag<string>();
|
||||
var errors = new ConcurrentQueue<Exception>();
|
||||
|
||||
var work = new Task[threads];
|
||||
for (var t = 0; t < threads; t++)
|
||||
{
|
||||
work[t] = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < perThread; i++)
|
||||
names.Add(TempFile.GenerateUniqueName());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Before the fix, a name collision throws ArgumentException from the
|
||||
// tracking dictionary's Add; capture it rather than faulting the task.
|
||||
errors.Enqueue(ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Task.WaitAll(work);
|
||||
|
||||
Assert.IsEmpty(errors, "Generating unique temp names must not throw: " + (errors.IsEmpty ? "" : errors.ToArray()[0].ToString()));
|
||||
|
||||
var all = names.ToArray();
|
||||
Assert.AreEqual(expected, all.Length, "Every generation should have produced a name");
|
||||
|
||||
var distinct = new HashSet<string>(all, StringComparer.Ordinal);
|
||||
Assert.AreEqual(all.Length, distinct.Count, "Generated temp file names must all be unique");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,27 @@ namespace Duplicati.UnitTest
|
||||
{
|
||||
public class ZipFallbackTest : BasicSetupHelper
|
||||
{
|
||||
private static async Task DeleteDatabaseWithRetryAsync(string path)
|
||||
{
|
||||
const int maxAttempts = 10;
|
||||
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (OperatingSystem.IsWindows() && attempt < maxAttempts)
|
||||
{
|
||||
// Windows can retain SQLite file handles until pending finalizers complete.
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Targeted")]
|
||||
public async Task FallbackToSharpCompressOnDecompressLzmaStreamsAsync()
|
||||
@@ -50,7 +71,7 @@ namespace Duplicati.UnitTest
|
||||
}
|
||||
|
||||
// Delete the local database
|
||||
File.Delete(DBFILE);
|
||||
await DeleteDatabaseWithRetryAsync(DBFILE);
|
||||
|
||||
// Switch back to built-in compression
|
||||
testopts.Remove("zip-compression-method");
|
||||
@@ -116,7 +137,7 @@ namespace Duplicati.UnitTest
|
||||
}
|
||||
|
||||
// Delete the local database
|
||||
File.Delete(DBFILE);
|
||||
await DeleteDatabaseWithRetryAsync(DBFILE);
|
||||
|
||||
// Recreate the database
|
||||
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// 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.
|
||||
|
||||
namespace Duplicati.WebserverCore.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks which local backup database files are currently in use by running operations,
|
||||
/// preventing concurrent access that would cause SQLite "database is locked" errors.
|
||||
/// </summary>
|
||||
public interface IDatabaseLockTracker
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to acquire a lock on the given database path, waiting until the lock
|
||||
/// is available. Used by queued tasks (backup, restore, etc.) which should wait
|
||||
/// for any concurrent immediate operations to finish.
|
||||
/// </summary>
|
||||
/// <param name="dbPath">The normalized, full path to the database file.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to cancel the wait.</param>
|
||||
/// <returns>A disposable handle that releases the lock when disposed.</returns>
|
||||
Task<IAsyncDisposable> AcquireAsync(string dbPath, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to acquire a lock on the given database path, failing immediately if
|
||||
/// the database is already in use. Used by immediate (non-queued) operations such
|
||||
/// as listing filesets from the restore UI.
|
||||
/// </summary>
|
||||
/// <param name="dbPath">The normalized, full path to the database file.</param>
|
||||
/// <returns>A disposable handle that releases the lock when disposed, or <c>null</c> if the database is already locked.</returns>
|
||||
IAsyncDisposable? TryAcquire(string dbPath);
|
||||
}
|
||||
@@ -45,12 +45,12 @@ public class BackupGet : IEndpointV1
|
||||
=> ExecuteGetFilesAsync(queueRunnerService, GetBackup(connection, id), filter, time, allVersions ?? false, prefixOnly ?? false, folderContents ?? false, new Dictionary<string, string>()))
|
||||
.RequireAuthorization();
|
||||
|
||||
group.MapGet("/backup/{id}/log", ([FromServices] Connection connection, [FromRoute] string id, [FromQuery] long? offset, [FromQuery] long? pagesize)
|
||||
=> ExecuteGetLog(connection, GetBackup(connection, id), offset, pagesize ?? 100))
|
||||
group.MapGet("/backup/{id}/log", ([FromServices] Connection connection, [FromServices] IDatabaseLockTracker databaseLockTracker, [FromRoute] string id, [FromQuery] long? offset, [FromQuery] long? pagesize)
|
||||
=> ExecuteGetLog(connection, databaseLockTracker, GetBackup(connection, id), offset, pagesize ?? 100))
|
||||
.RequireAuthorization();
|
||||
|
||||
group.MapGet("/backup/{id}/remotelog", ([FromServices] Connection connection, [FromRoute] string id, [FromQuery] long? offset, [FromQuery] long? pagesize)
|
||||
=> ExecuteGetRemotelog(connection, GetBackup(connection, id), offset, pagesize ?? 100))
|
||||
group.MapGet("/backup/{id}/remotelog", ([FromServices] Connection connection, [FromServices] IDatabaseLockTracker databaseLockTracker, [FromRoute] string id, [FromQuery] long? offset, [FromQuery] long? pagesize)
|
||||
=> ExecuteGetRemotelog(connection, databaseLockTracker, GetBackup(connection, id), offset, pagesize ?? 100))
|
||||
.RequireAuthorization();
|
||||
|
||||
group.MapGet("/backup/{id}/filesets", ([FromServices] Connection connection, [FromServices] IQueueRunnerService queueRunnerService, [FromRoute] string id, [FromQuery(Name = "include-metadata")] bool? includeMetadata, [FromQuery(Name = "from-remote-only")] bool? fromRemoteOnly)
|
||||
@@ -185,32 +185,56 @@ public class BackupGet : IEndpointV1
|
||||
private static async Task<Dictionary<string, object>> ExecuteGetFilesAsync(IQueueRunnerService queueRunnerService, IBackup bk, string? filter, string? timestring, bool allVersions, bool prefixOnly, bool folderContents, Dictionary<string, string> extraValues)
|
||||
=> await SearchFilesAsync(queueRunnerService, bk, filter, timestring, allVersions, prefixOnly, folderContents, extraValues).ConfigureAwait(false);
|
||||
|
||||
private static List<Dictionary<string, object>> ExecuteGetLog(Connection connection, IBackup bk, long? offset, long pagesize)
|
||||
private static async Task<List<Dictionary<string, object>>> ExecuteGetLog(Connection connection, IDatabaseLockTracker databaseLockTracker, IBackup bk, long? offset, long pagesize)
|
||||
{
|
||||
if (!File.Exists(bk.DBPath))
|
||||
// Use the effective database path (honoring a "--dbpath" advanced option) so that the log is
|
||||
// read from the same database the backup actually uses (see issue #1698).
|
||||
var dbpath = Runner.GetEffectiveDBPath(bk);
|
||||
if (!File.Exists(dbpath))
|
||||
return new List<Dictionary<string, object>>();
|
||||
|
||||
using (var con = Library.SQLiteHelper.SQLiteLoader.LoadConnection(bk.DBPath))
|
||||
using (var cmd = con.CreateCommand())
|
||||
return LogData.DumpTable(cmd, "LogData", "ID", offset, pagesize);
|
||||
var dbLock = databaseLockTracker.TryAcquire(dbpath)
|
||||
?? throw new DatabaseLockedException(dbpath);
|
||||
|
||||
try
|
||||
{
|
||||
using (var con = Library.SQLiteHelper.SQLiteLoader.LoadConnection(dbpath))
|
||||
using (var cmd = con.CreateCommand())
|
||||
return LogData.DumpTable(cmd, "LogData", "ID", offset, pagesize);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await dbLock.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Dictionary<string, object>> ExecuteGetRemotelog(Connection connection, IBackup bk, long? offset, long pagesize)
|
||||
private static async Task<List<Dictionary<string, object>>> ExecuteGetRemotelog(Connection connection, IDatabaseLockTracker databaseLockTracker, IBackup bk, long? offset, long pagesize)
|
||||
{
|
||||
if (!File.Exists(bk.DBPath))
|
||||
var dbpath = Runner.GetEffectiveDBPath(bk);
|
||||
if (!File.Exists(dbpath))
|
||||
return new List<Dictionary<string, object>>();
|
||||
|
||||
using (var con = Library.SQLiteHelper.SQLiteLoader.LoadConnection(bk.DBPath))
|
||||
using (var cmd = con.CreateCommand())
|
||||
var dbLock = databaseLockTracker.TryAcquire(dbpath)
|
||||
?? throw new DatabaseLockedException(dbpath);
|
||||
|
||||
try
|
||||
{
|
||||
var dt = LogData.DumpTable(cmd, "RemoteOperation", "ID", offset, pagesize);
|
||||
using (var con = Library.SQLiteHelper.SQLiteLoader.LoadConnection(dbpath))
|
||||
using (var cmd = con.CreateCommand())
|
||||
{
|
||||
var dt = LogData.DumpTable(cmd, "RemoteOperation", "ID", offset, pagesize);
|
||||
|
||||
// Unwrap raw data to a string
|
||||
foreach (var n in dt)
|
||||
try { n["Data"] = System.Text.Encoding.UTF8.GetString((byte[])n["Data"]); }
|
||||
catch { }
|
||||
// Unwrap raw data to a string
|
||||
foreach (var n in dt)
|
||||
try { n["Data"] = System.Text.Encoding.UTF8.GetString((byte[])n["Data"]); }
|
||||
catch { }
|
||||
|
||||
return dt;
|
||||
return dt;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await dbLock.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,9 @@ public class BackupPost : IEndpointV1
|
||||
=> connection.GetBackup(id) ?? throw new NotFoundException("Backup not found");
|
||||
|
||||
private static void ExecuteDeleteDb(IBackup backup)
|
||||
=> File.Delete(backup.DBPath);
|
||||
// Delete the effective database (honoring a "--dbpath" advanced option) so the database the
|
||||
// backup actually uses is removed, not a stale DBPath that may not exist (see issue #1698).
|
||||
=> File.Delete(Runner.GetEffectiveDBPath(backup));
|
||||
|
||||
private static void UpdateDatabasePath(Connection connection, IBackup backup, string targetpath, bool move)
|
||||
{
|
||||
|
||||
@@ -67,6 +67,7 @@ public static class ServiceCollectionsExtensions
|
||||
.AddSingleton<IRemoteControllerRegistration, RemoteControllerRegistrationService>()
|
||||
.AddSingleton<ISystemInfoProvider, SystemInfoProvider>()
|
||||
.AddSingleton<IQueueRunnerService, QueueRunnerService>()
|
||||
.AddSingleton<IDatabaseLockTracker, DatabaseLockTracker>()
|
||||
.AddSingleton<IProgressStateProviderService, ProgressStateProviderService>()
|
||||
.AddTransient<INotificationService, NotificationService>()
|
||||
.AddTransient<IBackupListService, BackupListService>()
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// 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.Collections.Concurrent;
|
||||
using Duplicati.WebserverCore.Abstractions;
|
||||
|
||||
namespace Duplicati.WebserverCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks which local backup database files are currently in use by running operations,
|
||||
/// preventing concurrent access that would cause SQLite "database is locked" errors.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each database path gets its own <see cref="SemaphoreSlim"/> (initial count 1).
|
||||
/// Queued tasks call <see cref="AcquireAsync"/> which waits for the lock.
|
||||
/// Immediate tasks call <see cref="TryAcquire"/> which fails instantly if locked.
|
||||
/// <para>
|
||||
/// Semaphore entries are intentionally not removed on release. Removing them is racy: a
|
||||
/// concurrent acquirer could take the lock between a release and a dictionary removal,
|
||||
/// after which a new acquirer would create a fresh semaphore for the same path, breaking
|
||||
/// mutual exclusion. The dictionary is bounded by the number of unique database paths
|
||||
/// (one per backup), which is a small, long-lived set, so the lack of cleanup does not
|
||||
/// cause unbounded growth in practice.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class DatabaseLockTracker : IDatabaseLockTracker
|
||||
{
|
||||
private const int MaxLockCount = 1;
|
||||
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new(Library.Utility.Utility.ClientFilenameStringComparer);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IAsyncDisposable> AcquireAsync(string dbPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var semaphore = GetOrCreateSemaphore(dbPath);
|
||||
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return new LockRelease(semaphore);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAsyncDisposable? TryAcquire(string dbPath)
|
||||
{
|
||||
var semaphore = GetOrCreateSemaphore(dbPath);
|
||||
if (!semaphore.Wait(0))
|
||||
return null;
|
||||
return new LockRelease(semaphore);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes the database path and gets (or creates) the semaphore for it.
|
||||
/// </summary>
|
||||
private SemaphoreSlim GetOrCreateSemaphore(string dbPath)
|
||||
{
|
||||
var normalized = NormalizePath(dbPath);
|
||||
if (string.IsNullOrEmpty(normalized))
|
||||
throw new ArgumentException("Database path cannot be null or empty", nameof(dbPath));
|
||||
return _locks.GetOrAdd(normalized, _ => new SemaphoreSlim(MaxLockCount, MaxLockCount));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a file path so that the same database file always maps to the same key,
|
||||
/// regardless of path separators or relative vs. absolute forms.
|
||||
/// </summary>
|
||||
private static string NormalizePath(string dbPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dbPath))
|
||||
return string.Empty;
|
||||
return Path.GetFullPath(dbPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A disposable handle that releases the semaphore when disposed.
|
||||
/// </summary>
|
||||
private sealed class LockRelease(SemaphoreSlim semaphore) : IAsyncDisposable
|
||||
{
|
||||
private int _released;
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _released, 1) == 0)
|
||||
semaphore.Release();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,8 @@ public class QueueRunnerService(
|
||||
EventPollNotify eventPollNotify,
|
||||
INotificationUpdateService notificationUpdateService,
|
||||
IProgressStateProviderService progressStateProviderService,
|
||||
IApplicationSettings applicationSettings) : IQueueRunnerService
|
||||
IApplicationSettings applicationSettings,
|
||||
IDatabaseLockTracker databaseLockTracker) : IQueueRunnerService, IDisposable
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
/// <summary>
|
||||
@@ -51,9 +52,10 @@ public class QueueRunnerService(
|
||||
private static readonly int MAX_TASK_RESULT_CACHE_SIZE = 100;
|
||||
|
||||
private readonly List<IQueuedTask> _tasks = new();
|
||||
private (Task? Task, IQueuedTask? QueuedTask) _current;
|
||||
private (Task? Task, IQueuedTask? QueuedTask, CancellationTokenSource? LockCts) _current;
|
||||
private bool _isPaused;
|
||||
private bool _isTerminated;
|
||||
private readonly CancellationTokenSource _terminateCts = new();
|
||||
|
||||
public long AddTask(IQueuedTask task)
|
||||
=> AddTask(task, false);
|
||||
@@ -101,6 +103,7 @@ public class QueueRunnerService(
|
||||
public void Terminate(bool wait)
|
||||
{
|
||||
_isTerminated = true;
|
||||
_terminateCts.Cancel();
|
||||
if (wait)
|
||||
{
|
||||
var task = _current.Task;
|
||||
@@ -118,20 +121,27 @@ public class QueueRunnerService(
|
||||
|
||||
// Clean up completed tasks
|
||||
if (_current.Task != null && _current.Task.IsCompleted)
|
||||
_current = (null, null);
|
||||
{
|
||||
_current.LockCts?.Dispose();
|
||||
_current = (null, null, null);
|
||||
}
|
||||
|
||||
if (_tasks.Count == 0)
|
||||
return;
|
||||
|
||||
var nextTask = _tasks[0];
|
||||
_tasks.RemoveAt(0);
|
||||
_current = (Task.Run(() => RunTaskAsync(nextTask), CancellationToken.None), nextTask);
|
||||
_current = (Task.Run(() => RunTaskAsync(nextTask), CancellationToken.None), nextTask, null);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunTaskAsync(IQueuedTask task)
|
||||
{
|
||||
var completed = false;
|
||||
IAsyncDisposable? dbLock = null;
|
||||
var lockCts = CancellationTokenSource.CreateLinkedTokenSource(_terminateCts.Token);
|
||||
lock (_lock)
|
||||
_current = (_current.Task, _current.QueuedTask, lockCts);
|
||||
try
|
||||
{
|
||||
eventPollNotify.SignalNewEvent();
|
||||
@@ -140,6 +150,8 @@ public class QueueRunnerService(
|
||||
if (task.OnStarting != null)
|
||||
await task.OnStarting().ConfigureAwait(false);
|
||||
|
||||
dbLock = await AcquireDatabaseLockAsync(task, lockCts.Token).ConfigureAwait(false);
|
||||
|
||||
await Runner.RunAsync(connection, eventPollNotify, notificationUpdateService, progressStateProviderService, applicationSettings, task, true).ConfigureAwait(false);
|
||||
|
||||
// If the task is completed, don't call OnFinished again
|
||||
@@ -160,9 +172,14 @@ public class QueueRunnerService(
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (dbLock != null)
|
||||
await dbLock.DisposeAsync().ConfigureAwait(false);
|
||||
task.TaskFinished = DateTime.UtcNow;
|
||||
lock (_lock)
|
||||
_current = (null, null);
|
||||
{
|
||||
lockCts.Dispose();
|
||||
_current = (null, null, null);
|
||||
}
|
||||
eventPollNotify.SignalNewEvent();
|
||||
eventPollNotify.SignalTaskQueueUpdate();
|
||||
eventPollNotify.SignalTaskCompleted(task.TaskID);
|
||||
@@ -215,6 +232,55 @@ public class QueueRunnerService(
|
||||
/// <inheritdoc/>
|
||||
public async Task<IBasicResults?> RunImmediatelyAsync(IQueuedTask task)
|
||||
{
|
||||
return await Runner.RunAsync(connection, eventPollNotify, notificationUpdateService, progressStateProviderService, applicationSettings, task, false).ConfigureAwait(false);
|
||||
IAsyncDisposable? dbLock = null;
|
||||
var dbPath = Runner.GetEffectiveDBPath(task);
|
||||
|
||||
// If we have a database to work with, make sure we can get the lock
|
||||
if (!string.IsNullOrWhiteSpace(dbPath))
|
||||
dbLock = databaseLockTracker.TryAcquire(dbPath)
|
||||
?? throw new DatabaseLockedException(dbPath);
|
||||
|
||||
try
|
||||
{
|
||||
return await Runner.RunAsync(connection, eventPollNotify, notificationUpdateService, progressStateProviderService, applicationSettings, task, false).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (dbLock != null)
|
||||
await dbLock.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void CancelCurrentTaskLockWait(long taskID)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_current.QueuedTask?.TaskID == taskID && _current.LockCts != null)
|
||||
_current.LockCts.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acquires the database lock for a queued task, waiting until the lock is available
|
||||
/// or the cancellation token is cancelled. Tasks without a database path (e.g. custom
|
||||
/// runner tasks) skip locking.
|
||||
/// </summary>
|
||||
private async Task<IAsyncDisposable?> AcquireDatabaseLockAsync(IQueuedTask task, CancellationToken cancellationToken)
|
||||
{
|
||||
var dbPath = Runner.GetEffectiveDBPath(task);
|
||||
if (string.IsNullOrWhiteSpace(dbPath))
|
||||
return null;
|
||||
|
||||
return await databaseLockTracker.AcquireAsync(dbPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes resources held by the queue runner, including the termination
|
||||
/// cancellation token source used to cancel tasks waiting on the database lock.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_terminateCts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,8 @@ public class TaskQueueService(IQueueRunnerService queueRunnerService) : ITaskQue
|
||||
if (task == null)
|
||||
throw new NotFoundException("No such task found");
|
||||
|
||||
queueRunnerService.CancelCurrentTaskLockWait(task.TaskID);
|
||||
|
||||
if (abort)
|
||||
await task.AbortAsync().ConfigureAwait(false);
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user