diff --git a/.github/workflows/backendtests.yml b/.github/workflows/backendtests.yml index 059854010..9cd5239df 100644 --- a/.github/workflows/backendtests.yml +++ b/.github/workflows/backendtests.yml @@ -13,6 +13,8 @@ jobs: testcontainers_secrets_available: "${{ steps.check_testcontainers.outputs.available }}" google_secrets_available: "${{ steps.check_google.outputs.available }}" pcloud_secrets_available: "${{ steps.check_pcloud.outputs.available }}" + b2_secrets_available: "${{ steps.check_b2.outputs.available }}" + box_secrets_available: "${{ steps.check_box.outputs.available }}" steps: - id: check_pcloud name: Check pCloud secrets @@ -103,6 +105,36 @@ jobs: fi echo "Google check completed" + - id: check_b2 + name: Check B2 secrets + shell: bash + run: | + echo "Starting B2 secrets check..." + if [[ -n "${{ secrets.TESTCREDENTIAL_B2_BUCKET }}" ]] && \ + [[ -n "${{ secrets.TESTCREDENTIAL_B2_FOLDER }}" ]] && \ + [[ -n "${{ secrets.TESTCREDENTIAL_B2_USERNAME }}" ]] && \ + [[ -n "${{ secrets.TESTCREDENTIAL_B2_PASSWORD }}" ]]; then + echo "All B2 secrets found" + echo "available=true" >> $GITHUB_OUTPUT + else + echo "Missing some B2 secrets" + echo "available=false" >> $GITHUB_OUTPUT + fi + echo "B2 check completed" + - id: check_box + name: Check Box.com secrets + shell: bash + run: | + echo "Starting Box.com secrets check..." + if [[ -n "${{ secrets.TESTCREDENTIAL_BOX_FOLDER }}" ]] && \ + [[ -n "${{ secrets.TESTCREDENTIAL_BOX_AUTHID }}" ]]; then + echo "All Box.com secrets found" + echo "available=true" >> $GITHUB_OUTPUT + else + echo "Missing some Box.com secrets" + echo "available=false" >> $GITHUB_OUTPUT + fi + echo "Box.com check completed" - name: Debug Output shell: bash run: | @@ -110,6 +142,10 @@ jobs: echo "Dropbox Available: ${{ steps.check_dropbox.outputs.available }}" echo "TestContainers Available: ${{ steps.check_testcontainers.outputs.available }}" echo "Google Available: ${{ steps.check_google.outputs.available }}" + echo "pCloud Available: ${{ steps.check_pcloud.outputs.available }}" + echo "Azure Available: ${{ steps.check_azure.outputs.available }}" + echo "B2 Available: ${{ steps.check_b2.outputs.available }}" + echo "Box.com Available: ${{ steps.check_box.outputs.available }}" test_ftp: needs: check_secrets if: needs.check_secrets.outputs.testcontainers_secrets_available == 'true' @@ -257,6 +293,7 @@ jobs: LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln - name: Run Azure tests env: + READ_WRITE_TIMEOUT_SECONDS: 18000 TESTCREDENTIAL_AZURE_ACCOUNTNAME: "${{ secrets.TESTCREDENTIAL_AZURE_ACCOUNTNAME }}" TESTCREDENTIAL_AZURE_ACCESSKEY: "${{ secrets.TESTCREDENTIAL_AZURE_ACCESSKEY }}" TESTCREDENTIAL_AZURE_CONTAINERNAME: "${{ secrets.TESTCREDENTIAL_AZURE_CONTAINERNAME }}" @@ -415,3 +452,108 @@ jobs: dotnet test --no-build --filter="ClassName~GoogleDriveTests" --logger:"console;verbosity=detailed" LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln + test_cifs: + needs: check_secrets + if: needs.check_secrets.outputs.testcontainers_secrets_available == 'true' + name: CIFS Tests + runs-on: "${{ matrix.os }}" + strategy: + max-parallel: 1 + fail-fast: false + matrix: + os: + - ubuntu-latest + steps: + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.x + - name: Checkout source + uses: actions/checkout@v4 + - name: Restore NuGet dependencies + run: >- + dotnet restore + LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln + - name: Build project + run: >- + dotnet build --no-restore + LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln + - name: Run CIFS tests + run: >- + dotnet test --no-build --filter="ClassName~CIFSTests" + --logger:"console;verbosity=detailed" + LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln + test_b2: + needs: check_secrets + if: needs.check_secrets.outputs.b2_secrets_available == 'true' + name: Backblaze B2 Tests + runs-on: "${{ matrix.os }}" + strategy: + max-parallel: 1 + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + - macos-latest + steps: + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.x + - name: Checkout source + uses: actions/checkout@v4 + - name: Restore NuGet dependencies + run: >- + dotnet restore + LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln + - name: Build project + run: >- + dotnet build --no-restore + LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln + - name: Run B2 tests + env: + TESTCREDENTIAL_B2_BUCKET: "${{ secrets.TESTCREDENTIAL_B2_BUCKET }}" + TESTCREDENTIAL_B2_FOLDER: "${{ secrets.TESTCREDENTIAL_B2_FOLDER }}" + TESTCREDENTIAL_B2_USERNAME: "${{ secrets.TESTCREDENTIAL_B2_USERNAME }}" + TESTCREDENTIAL_B2_PASSWORD: "${{ secrets.TESTCREDENTIAL_B2_PASSWORD }}" + run: >- + dotnet test --no-build --filter="ClassName~B2Tests" + --logger:"console;verbosity=detailed" + LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln + test_box: + needs: check_secrets + if: needs.check_secrets.outputs.box_secrets_available == 'true' + name: Box.com Tests + runs-on: "${{ matrix.os }}" + strategy: + max-parallel: 1 + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + - macos-latest + steps: + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.x + - name: Checkout source + uses: actions/checkout@v4 + - name: Restore NuGet dependencies + run: >- + dotnet restore + LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln + - name: Build project + run: >- + dotnet build --no-restore + LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln + - name: Run Box.com tests + env: + TESTCREDENTIAL_BOX_FOLDER: "${{ secrets.TESTCREDENTIAL_BOX_FOLDER }}" + TESTCREDENTIAL_BOX_AUTHID: "${{ secrets.TESTCREDENTIAL_BOX_AUTHID }}" + run: >- + dotnet test --no-build --filter="ClassName~Box.BoxTests" + --logger:"console;verbosity=detailed" + LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 67e1244ea..0f0d4b5ed 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,9 +29,10 @@ jobs: - name: Run unit tests run: dotnet test --no-build --verbosity minimal Duplicati.sln - selenium: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Selenium - run: pipeline/selenium/test.sh + # Disabled, as a new test needs to be written for the new UI + # selenium: + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # - name: Selenium + # run: pipeline/selenium/test.sh diff --git a/.vscode/launch.json b/.vscode/launch.json index ba69c97a3..e114a9d04 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -114,6 +114,13 @@ "stopAtEntry": false, "console": "internalConsole" }, - + { + "name": "Launch SourceTool executable", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.SourceTool/bin/Debug/net8.0/Duplicati.CommandLine.SourceTool", + "args": [] + } ] } \ No newline at end of file diff --git a/Duplicati.sln b/Duplicati.sln index 879a03397..e9f8d2405 100644 --- a/Duplicati.sln +++ b/Duplicati.sln @@ -166,6 +166,22 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.Crashlog" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.Backend.CIFS", "Duplicati\Library\Backend\CIFS\Duplicati.Library.Backend.CIFS.csproj", "{836E0557-B40C-4DC7-9A2A-5C062F9ACC6B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.SourceProviders", "Duplicati\Library\SourceProviders\Duplicati.Library.SourceProviders.csproj", "{8A2C7A9F-3EC8-4DE5-A9FA-9E4BCF955EF3}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SourceProviders", "SourceProviders", "{424CEF73-4984-430E-9C8B-E61CC0F22074}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.SourceProvider.Builtin", "Duplicati\Library\SourceProvider\Builtin\Duplicati.Library.SourceProvider.Builtin.csproj", "{6D9742A7-6F32-4571-8276-79716168B525}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{15388C37-9218-4818-972E-738EEA8F1602}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RemoteSynchronization", "Tools\RemoteSynchronization\RemoteSynchronization.csproj", "{D3A7E41E-279D-4E0A-A2DB-5E8002E02B6D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.CommandLine.SyncTool", "Executables\net8\Duplicati.CommandLine.SyncTool\Duplicati.CommandLine.SyncTool.csproj", "{48D32674-0FE9-4407-B102-4AE46D93595F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.CommandLine.SourceTool", "Duplicati\CommandLine\SourceTool\Duplicati.CommandLine.SourceTool.csproj", "{2873BCE2-2EA6-419D-BFFA-693FDB34926E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.CommandLine.SourceTool", "Executables\net8\Duplicati.CommandLine.SourceTool\Duplicati.CommandLine.SourceTool.csproj", "{4003BF90-6681-4155-9D06-639ED23FE3AB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -472,6 +488,30 @@ Global {836E0557-B40C-4DC7-9A2A-5C062F9ACC6B}.Debug|Any CPU.Build.0 = Debug|Any CPU {836E0557-B40C-4DC7-9A2A-5C062F9ACC6B}.Release|Any CPU.ActiveCfg = Release|Any CPU {836E0557-B40C-4DC7-9A2A-5C062F9ACC6B}.Release|Any CPU.Build.0 = Release|Any CPU + {8A2C7A9F-3EC8-4DE5-A9FA-9E4BCF955EF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8A2C7A9F-3EC8-4DE5-A9FA-9E4BCF955EF3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A2C7A9F-3EC8-4DE5-A9FA-9E4BCF955EF3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8A2C7A9F-3EC8-4DE5-A9FA-9E4BCF955EF3}.Release|Any CPU.Build.0 = Release|Any CPU + {6D9742A7-6F32-4571-8276-79716168B525}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6D9742A7-6F32-4571-8276-79716168B525}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6D9742A7-6F32-4571-8276-79716168B525}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6D9742A7-6F32-4571-8276-79716168B525}.Release|Any CPU.Build.0 = Release|Any CPU + {D3A7E41E-279D-4E0A-A2DB-5E8002E02B6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3A7E41E-279D-4E0A-A2DB-5E8002E02B6D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3A7E41E-279D-4E0A-A2DB-5E8002E02B6D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3A7E41E-279D-4E0A-A2DB-5E8002E02B6D}.Release|Any CPU.Build.0 = Release|Any CPU + {48D32674-0FE9-4407-B102-4AE46D93595F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {48D32674-0FE9-4407-B102-4AE46D93595F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {48D32674-0FE9-4407-B102-4AE46D93595F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {48D32674-0FE9-4407-B102-4AE46D93595F}.Release|Any CPU.Build.0 = Release|Any CPU + {2873BCE2-2EA6-419D-BFFA-693FDB34926E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2873BCE2-2EA6-419D-BFFA-693FDB34926E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2873BCE2-2EA6-419D-BFFA-693FDB34926E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2873BCE2-2EA6-419D-BFFA-693FDB34926E}.Release|Any CPU.Build.0 = Release|Any CPU + {4003BF90-6681-4155-9D06-639ED23FE3AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4003BF90-6681-4155-9D06-639ED23FE3AB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4003BF90-6681-4155-9D06-639ED23FE3AB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4003BF90-6681-4155-9D06-639ED23FE3AB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -532,6 +572,11 @@ Global {CA1A0EE8-DFFF-42AF-B15D-EF538357A2DE} = {E1A9B303-F281-45C5-A4F6-CADD9DE3F3C4} {8ACA2736-3C69-4FA5-BCF9-5EDF50CAF332} = {566EBBDA-19A4-4056-A615-D901D57D2439} {836E0557-B40C-4DC7-9A2A-5C062F9ACC6B} = {E1A9B303-F281-45C5-A4F6-CADD9DE3F3C4} + {6D9742A7-6F32-4571-8276-79716168B525} = {424CEF73-4984-430E-9C8B-E61CC0F22074} + {D3A7E41E-279D-4E0A-A2DB-5E8002E02B6D} = {15388C37-9218-4818-972E-738EEA8F1602} + {48D32674-0FE9-4407-B102-4AE46D93595F} = {6B46F6B1-1898-49B8-ADA7-5CAF68EB77E3} + {2873BCE2-2EA6-419D-BFFA-693FDB34926E} = {D19A38DD-68F1-4EF5-BF5F-8966CE0D9A5B} + {4003BF90-6681-4155-9D06-639ED23FE3AB} = {6B46F6B1-1898-49B8-ADA7-5CAF68EB77E3} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {8B40BAFE-D862-4397-9495-8F5EAF5CE80C} diff --git a/Duplicati/Agent/Program.cs b/Duplicati/Agent/Program.cs index 285784511..c02ecbdd0 100644 --- a/Duplicati/Agent/Program.cs +++ b/Duplicati/Agent/Program.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System.CommandLine; @@ -25,6 +25,7 @@ using System.CommandLine.NamingConventionBinder; using System.CommandLine.Parsing; using System.Reactive.Linq; using System.Security.Cryptography; +using Duplicati.Library.AutoUpdater; using Duplicati.Library.Encryption; using Duplicati.Library.Interface; using Duplicati.Library.Logging; @@ -33,7 +34,7 @@ using Duplicati.Library.RemoteControl; using Duplicati.Library.RestAPI; using Duplicati.Library.Utility; using Duplicati.Server; -using Duplicati.WebserverCore.Abstractions; +using Duplicati.WebserverCore.Services; using Microsoft.Extensions.DependencyInjection; namespace Duplicati.Agent; @@ -45,12 +46,6 @@ public static class Program /// private static readonly string LogTag = Log.LogTagFromType(typeof(Program)); - /// - /// Create a random pre-shared key for the agent - /// - private static string? PreSharedKey = null; - - /// /// Commandline arguments for the agent /// @@ -123,6 +118,8 @@ public static class Program new Option("--secret-provider", description: "The secret provider to use", getDefaultValue: () => null), new Option("--secret-provider-cache", description: "The secret provider cache level", getDefaultValue: () => SecretProviderHelper.CachingLevel.None), new Option("--secret-provider-pattern", description: "The secret provider pattern", getDefaultValue: () => SecretProviderHelper.DEFAULT_PATTERN), + new Option($"--{DataFolderManager.PORTABLE_MODE_OPTION}", description: "Use portable mode for locating the database and storing configuration", getDefaultValue: () => DataFolderManager.PORTABLE_MODE), + new Option($"--{DataFolderManager.SERVER_DATAFOLDER_OPTION}", description: "The datafolder to use for locating the database and storing configuration", getDefaultValue: () => new DirectoryInfo(DataFolderManager.DATAFOLDER)), }; runcmd.Handler = CommandHandler.Create(RunAgent); @@ -166,6 +163,7 @@ public static class Program context.ExitCode = 1; } }) + .UseAdditionalHelpAliases() .Build() .InvokeAsync(args); } @@ -271,7 +269,7 @@ public static class Program // Set the pre-shared key for the agent if (!agentConfig.DisablePreSharedKey) - WebserverCore.Middlewares.PreSharedKeyFilter.PreSharedKey = PreSharedKey = RandomNumberGenerator.GetHexString(128); + WebserverCore.Middlewares.PreSharedKeyFilter.PreSharedKey = RandomNumberGenerator.GetHexString(128); using var cts = new CancellationTokenSource(); @@ -309,8 +307,10 @@ public static class Program settings.CertificateUrl, settings.ServerCertificates, cts.Token, - c => ReKey(agentConfig, c), - (m) => OnMessage(m, agentConfig) + OnConnect, + m => ReKey(m, agentConfig), + OnControl, + OnMessage ) ); @@ -320,24 +320,19 @@ public static class Program } } - private static async Task OnMessage(KeepRemoteConnection.CommandMessage message, CommandLineArguments agentConfig) - { - Log.WriteMessage(LogMessageType.Verbose, LogTag, "OnMessage", "Received message: {0}", message); - - var provider = FIXMEGlobal.Provider.GetRequiredService(); - var token = provider.CreateAccessToken("agent", provider.TemporaryFamilyId, TimeSpan.FromMinutes(2)); - using var httpClient = FIXMEGlobal.Provider.GetRequiredService().CreateClient(); - httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - httpClient.BaseAddress = new System.Uri($"http://127.0.0.1:{agentConfig.WebservicePort}"); - - if (!string.IsNullOrWhiteSpace(WebserverCore.Middlewares.PreSharedKeyFilter.PreSharedKey)) - httpClient.DefaultRequestHeaders.Add(WebserverCore.Middlewares.PreSharedKeyFilter.HeaderName, PreSharedKey); - - await message.Handle(httpClient); - } - - private static Task ReKey(CommandLineArguments agentConfig, ClaimedClientData keydata) + private static Task> OnConnect(Dictionary metadata) + => FIXMEGlobal.Provider.GetRequiredService().OnConnect(metadata); + + private static Task OnControl(KeepRemoteConnection.ControlMessage message) + => FIXMEGlobal.Provider.GetRequiredService().OnControl(message); + + private static Task OnMessage(KeepRemoteConnection.CommandMessage message) + => FIXMEGlobal.Provider.GetRequiredService().OnMessage(message); + + + private static Task ReKey(ClaimedClientData keydata, CommandLineArguments agentConfig) { + // ReKey is handled here because we store the config outside of the database Log.WriteMessage(LogMessageType.Verbose, LogTag, "ReKey", "Rekeying the settings"); var settings = Settings.Load(agentConfig.AgentSettingsFile.FullName, agentConfig.AgentSettingsFilePassphrase); if (!string.IsNullOrWhiteSpace(keydata.JWT) && settings.JWT != keydata.JWT) diff --git a/Duplicati/Agent/Settings.cs b/Duplicati/Agent/Settings.cs index 759edd389..079fd626f 100644 --- a/Duplicati/Agent/Settings.cs +++ b/Duplicati/Agent/Settings.cs @@ -148,10 +148,8 @@ public sealed record Settings( /// The path to the settings file private static string GetSettingsPath() { - var folder = DatabaseLocator.GetDefaultStorageFolderWithDebugSupport(DefaultSettingsFilename); - if (!Directory.Exists(folder)) - Directory.CreateDirectory(folder); - + // Ideally, this should use DataFolderManager.DATAFOLDER, but we cannot due to backwards compatibility + var folder = Library.AutoUpdater.DataFolderLocator.GetDefaultStorageFolder(DefaultSettingsFilename, true); return Path.Combine(folder, DefaultSettingsFilename); } } diff --git a/Duplicati/CommandLine/BackendTester/Program.cs b/Duplicati/CommandLine/BackendTester/Program.cs index a93873ddd..138226927 100644 --- a/Duplicati/CommandLine/BackendTester/Program.cs +++ b/Duplicati/CommandLine/BackendTester/Program.cs @@ -29,6 +29,7 @@ using System.Globalization; using System.Threading; using Duplicati.Library.Utility; using Duplicati.StreamUtil; +using System.Threading.Tasks; namespace Duplicati.CommandLine.BackendTester { @@ -83,7 +84,7 @@ namespace Duplicati.CommandLine.BackendTester List args = new List(_args); Dictionary options = Library.Utility.CommandLineParser.ExtractOptions(args); - if (args.Count != 1 || String.Equals(args[0], "help", StringComparison.OrdinalIgnoreCase) || args[0] == "?") + if (args.Count != 1 || HelpOptionExtensions.IsArgumentAnyHelpString(args)) { Console.WriteLine("Usage: ://:@"); Console.WriteLine("Example: ftp://user:pass@server/folder"); @@ -118,16 +119,17 @@ namespace Duplicati.CommandLine.BackendTester for (int i = 0; i < reruns; i++) { - Console.WriteLine("Starting run no {0}", i); + Console.WriteLine(DateTime.Now.ToShortDateString() + " " + DateTime.Now.ToLongTimeString()); + Console.WriteLine(LogTimeStamp + "Starting run no {0}", i); if (!Run(args, options, i == 0)) return 1; } - Console.WriteLine("Unittest complete!"); + Console.WriteLine(LogTimeStamp + "Unittest complete!"); return 0; } catch (Exception ex) { - Console.WriteLine("Unittest failed: " + ex); + Console.WriteLine(LogTimeStamp + "Unittest failed: " + ex); } return 1; @@ -172,7 +174,7 @@ namespace Duplicati.CommandLine.BackendTester try { backend.TestAsync(CancellationToken.None).Await(); - curlist = backend.List(); + curlist = backend.ListAsync(CancellationToken.None).ToBlockingEnumerable().ToList(); } catch (FolderMissingException) { @@ -181,11 +183,11 @@ namespace Duplicati.CommandLine.BackendTester try { backend.CreateFolderAsync(CancellationToken.None).Await(); - curlist = backend.List(); + curlist = backend.ListAsync(CancellationToken.None).ToBlockingEnumerable().ToList(); } catch (Exception ex) { - Console.WriteLine("Autocreate folder failed with message: " + ex.Message); + Console.WriteLine(LogTimeStamp + "Autocreate folder failed with message: " + ex.Message); } } @@ -199,7 +201,7 @@ namespace Duplicati.CommandLine.BackendTester if (Library.Utility.Utility.ParseBoolOption(options, "auto-clean") && first) if (Library.Utility.Utility.ParseBoolOption(options, "force")) { - Console.WriteLine("Auto clean, removing file: {0}", fe.Name); + Console.WriteLine(LogTimeStamp + "Auto clean, removing file: {0}", fe.Name); backend.DeleteAsync(fe.Name, CancellationToken.None).Await(); continue; } @@ -208,10 +210,10 @@ namespace Duplicati.CommandLine.BackendTester var fileCount = curlist.Where(x => !x.IsFolder).Count(); var filenames = curlist.Where(x => !x.IsFolder).Select(x => x.Name).Take(10).ToList(); - Console.WriteLine($"*** Remote folder contains {fileCount} file(s), aborting"); - Console.WriteLine($"*** First {filenames.Count} file(s): {Environment.NewLine}{string.Join(Environment.NewLine, filenames)}"); + Console.WriteLine(LogTimeStamp + $"*** Remote folder contains {fileCount} file(s), aborting"); + Console.WriteLine(LogTimeStamp + $"*** First {filenames.Count} file(s): {Environment.NewLine}{string.Join(Environment.NewLine, filenames)}"); if (fileCount > filenames.Count) - Console.WriteLine($"*** ... and {fileCount - filenames.Count} more file(s)"); + Console.WriteLine(LogTimeStamp + $"*** ... and {fileCount - filenames.Count} more file(s)"); return false; } @@ -232,7 +234,7 @@ namespace Duplicati.CommandLine.BackendTester { if (!(backend is IStreamingBackend) || disableStreaming) { - Console.WriteLine("Warning: Throttling is only supported in this tool on streaming backends"); + Console.WriteLine(LogTimeStamp + "Warning: Throttling is only supported in this tool on streaming backends"); } throttleUpload = Duplicati.Library.Utility.Sizeparser.ParseSize(throttleUploadString, "kb"); @@ -243,15 +245,25 @@ namespace Duplicati.CommandLine.BackendTester { if (!(backend is IStreamingBackend) || disableStreaming) { - Console.WriteLine("Warning: Throttling is only supported in this tool on streaming backends"); + Console.WriteLine(LogTimeStamp + "Warning: Throttling is only supported in this tool on streaming backends"); } throttleDownload = Duplicati.Library.Utility.Sizeparser.ParseSize(throttleDownloadString, "kb"); } - var readWriteTimeout = (int)TimeSpan.FromSeconds(10).TotalMilliseconds; - if (backend is ITimeoutExemptBackend) - readWriteTimeout = Timeout.Infinite; + int readWriteTimeout = backend is ITimeoutExemptBackend || + (Environment.GetEnvironmentVariable("READ_WRITE_TIMEOUT_SECONDS") is { } timeout + && int.TryParse(timeout, out var seconds) + && seconds == -1) + ? Timeout.Infinite + : Environment.GetEnvironmentVariable("READ_WRITE_TIMEOUT_SECONDS") is { } timeoutRetry + && int.TryParse(timeoutRetry, out var secondsRetry) + ? (int)TimeSpan.FromSeconds(secondsRetry).TotalMilliseconds + : (int)TimeSpan.FromSeconds(60).TotalMilliseconds; + + Console.WriteLine(LogTimeStamp + "Read Write Timeout set to {0}{1}", + readWriteTimeout == Timeout.Infinite ? "infinite" : readWriteTimeout + " ms", + backend is ITimeoutExemptBackend ? " (because implements ITimeoutExemptBackend)" : ""); // Allow overriding the timeout for the backend here, even if timeouts are disabled if (options.TryGetValue("read-write-timeout", out var readWriteTimeoutString)) @@ -298,7 +310,7 @@ namespace Duplicati.CommandLine.BackendTester byte[] dummyFileHash = null; if (!skipOverwriteTest) { - Console.WriteLine("Uploading wrong files ..."); + Console.WriteLine(LogTimeStamp + "Uploading wrong files ..."); using (var dummy = Library.Utility.TempFile.WrapExistingFile(CreateRandomFile(tf, files.Count, 1024, 2048, rnd))) { using (var fs = new System.IO.FileStream(dummy, System.IO.FileMode.Open, System.IO.FileAccess.Read)) @@ -313,7 +325,7 @@ namespace Duplicati.CommandLine.BackendTester } - Console.WriteLine("Uploading files ..."); + Console.WriteLine(LogTimeStamp + "Uploading files ..."); for (int i = 0; i < files.Count; i++) Uploadfile(files[i].localfilename, i, files[i].remotefilename, backend, disableStreaming, throttleUpload, readWriteTimeout); @@ -328,7 +340,7 @@ namespace Duplicati.CommandLine.BackendTester renamedFileNewName = CreateRandomRemoteFileName(min_filename_size, max_filename_size, allowedChars, trimFilenameSpaces, rnd); - Console.WriteLine("Renaming file {0} from {1} to {2}", renameIndex, originalRenamedFile.remotefilename, renamedFileNewName); + Console.WriteLine(LogTimeStamp + "Renaming file {0} from {1} to {2}", renameIndex, originalRenamedFile.remotefilename, renamedFileNewName); renameEnabledBackend.RenameAsync(originalRenamedFile.remotefilename, renamedFileNewName, CancellationToken.None).Await(); files[renameIndex] = new TempFile(renamedFileNewName, originalRenamedFile.localfilename, originalRenamedFile.hash, originalRenamedFile.length); @@ -336,13 +348,13 @@ namespace Duplicati.CommandLine.BackendTester if (waitAfterUpload > TimeSpan.Zero) { - Console.WriteLine("Waiting {0} after upload", waitAfterUpload); + Console.WriteLine(LogTimeStamp + "Waiting {0} after upload", waitAfterUpload); Thread.Sleep(waitAfterUpload); } - Console.WriteLine("Verifying file list ..."); + Console.WriteLine(LogTimeStamp + "Verifying file list ..."); - curlist = backend.List(); + curlist = backend.ListAsync(CancellationToken.None).ToBlockingEnumerable().ToList(); foreach (var fe in curlist) if (!fe.IsFolder) { @@ -351,12 +363,12 @@ namespace Duplicati.CommandLine.BackendTester if (tx.remotefilename == fe.Name) { if (tx.found) - Console.WriteLine("*** File with name {0} was found more than once", tx.remotefilename); + Console.WriteLine(LogTimeStamp + "*** File with name {0} was found more than once", tx.remotefilename); found = true; tx.found = true; if (fe.Size > 0 && tx.length != fe.Size) - Console.WriteLine("*** File with name {0} has size {1} but the size was reported as {2}", tx.remotefilename, tx.length, fe.Size); + Console.WriteLine(LogTimeStamp + "*** File with name {0} has size {1} but the size was reported as {2}", tx.remotefilename, tx.length, fe.Size); break; } @@ -364,26 +376,26 @@ namespace Duplicati.CommandLine.BackendTester if (!found) if (originalRenamedFile != null && renamedFileNewName != null && originalRenamedFile.remotefilename == fe.Name) { - Console.WriteLine("*** File with name {0} was found on server but was supposed to have been renamed to {1}!", fe.Name, renamedFileNewName); + Console.WriteLine(LogTimeStamp + "*** File with name {0} was found on server but was supposed to have been renamed to {1}!", fe.Name, renamedFileNewName); } else { - Console.WriteLine("*** File with name {0} was found on server but not uploaded!", fe.Name); + Console.WriteLine(LogTimeStamp + "*** File with name {0} was found on server but not uploaded!", fe.Name); } } foreach (var tx in files) if (!tx.found) - Console.WriteLine("*** File with name {0} was uploaded but not found afterwards", tx.remotefilename); + Console.WriteLine(LogTimeStamp + "*** File with name {0} was uploaded but not found afterwards", tx.remotefilename); - Console.WriteLine("Downloading files"); + Console.WriteLine(LogTimeStamp + "Downloading files"); for (int i = 0; i < files.Count; i++) { using (var cf = new Duplicati.Library.Utility.TempFile()) { Exception e = null; - Console.Write("Downloading file {0} ... ", i); + Console.Write(LogTimeStamp + "Downloading file {0} ... ", i); var sw = Stopwatch.StartNew(); @@ -410,22 +422,22 @@ namespace Duplicati.CommandLine.BackendTester if (e != null) { failAfterFinished = true; - Console.WriteLine($"failed\n*** Error: {e} after {sw.ElapsedMilliseconds} ms"); + Console.WriteLine(LogTimeStamp + $"failed\n*** Error: {e} after {sw.ElapsedMilliseconds} ms"); } else - Console.WriteLine($"done in {sw.ElapsedMilliseconds} ms"); + Console.WriteLine($" done in {sw.ElapsedMilliseconds} ms"); - Console.Write("Checking hash ... "); + Console.Write(LogTimeStamp + "Checking hash ... "); using (var fs = new System.IO.FileStream(cf, System.IO.FileMode.Open, System.IO.FileAccess.Read)) if (Convert.ToBase64String(sha.ComputeHash(fs)) != Convert.ToBase64String(files[i].hash)) { if (dummyFileHash != null && Convert.ToBase64String(sha.ComputeHash(fs)) == Convert.ToBase64String(dummyFileHash)) - Console.WriteLine("failed\n*** Downloaded file was the dummy file"); // Should this be failed? + Console.WriteLine(LogTimeStamp + "failed\n*** Downloaded file was the dummy file"); // Should this be failed? else { failAfterFinished = true; - Console.WriteLine("failed\n*** Downloaded file was corrupt"); + Console.WriteLine(LogTimeStamp + "failed\n*** Downloaded file was corrupt"); } } else @@ -433,31 +445,31 @@ namespace Duplicati.CommandLine.BackendTester } } - Console.WriteLine("Deleting files..."); + Console.WriteLine(LogTimeStamp + "Deleting files..."); foreach (var tx in files) try { backend.DeleteAsync(tx.remotefilename, CancellationToken.None).Await(); } catch (Exception ex) { failAfterFinished = true; - Console.WriteLine("*** Failed to delete file {0}, message: {1}", tx.remotefilename, ex); + Console.WriteLine(LogTimeStamp + "*** Failed to delete file {0}, message: {1}", tx.remotefilename, ex); } if (waitAfterDelete > TimeSpan.Zero) { - Console.WriteLine("Waiting {0} after delete", waitAfterDelete); + Console.WriteLine(LogTimeStamp + "Waiting {0} after delete", waitAfterDelete); Thread.Sleep(waitAfterDelete); } - curlist = backend.List(); + curlist = backend.ListAsync(CancellationToken.None).ToBlockingEnumerable().ToList(); foreach (var fe in curlist) if (!fe.IsFolder) { - Console.WriteLine("*** Remote folder contains {0} after cleanup", fe.Name); + Console.WriteLine(LogTimeStamp + "*** Remote folder contains {0} after cleanup", fe.Name); } // Test some error cases - Console.WriteLine("Checking retrieval of non-existent file..."); + Console.WriteLine(LogTimeStamp + "Checking retrieval of non-existent file..."); var caughtExpectedException = false; try { @@ -468,25 +480,25 @@ namespace Duplicati.CommandLine.BackendTester } catch (FileMissingException) { - Console.WriteLine("Caught expected FileMissingException"); + Console.WriteLine(LogTimeStamp + "Caught expected FileMissingException"); caughtExpectedException = true; } catch (Exception ex) { - Console.WriteLine("*** Retrieval of non-existent file failed: {0}", ex); + Console.WriteLine(LogTimeStamp + "*** Retrieval of non-existent file failed: {0}", ex); } if (!caughtExpectedException) { failAfterFinished = true; - Console.WriteLine("*** Retrieval of non-existent file should have failed with FileMissingException"); + Console.WriteLine(LogTimeStamp + "*** Retrieval of non-existent file should have failed with FileMissingException"); } } // Test quota retrieval if (backend is IQuotaEnabledBackend quotaEnabledBackend) { - Console.WriteLine("Checking quota..."); + Console.WriteLine(LogTimeStamp + "Checking quota..."); IQuotaInfo quota = null; bool noException; try @@ -496,7 +508,7 @@ namespace Duplicati.CommandLine.BackendTester } catch (Exception ex) { - Console.WriteLine("*** Checking quota information failed: {0}", ex); + Console.WriteLine(LogTimeStamp + "*** Checking quota information failed: {0}", ex); noException = false; } @@ -504,18 +516,18 @@ namespace Duplicati.CommandLine.BackendTester { if (quota != null) { - Console.WriteLine("Free Space: {0}", Library.Utility.Utility.FormatSizeString(quota.FreeQuotaSpace)); - Console.WriteLine("Total Space: {0}", Library.Utility.Utility.FormatSizeString(quota.TotalQuotaSpace)); + Console.WriteLine(LogTimeStamp + "Free Space: {0}", Library.Utility.Utility.FormatSizeString(quota.FreeQuotaSpace)); + Console.WriteLine(LogTimeStamp + "Total Space: {0}", Library.Utility.Utility.FormatSizeString(quota.TotalQuotaSpace)); } else { - Console.WriteLine("Unable to retrieve quota information"); + Console.WriteLine(LogTimeStamp + "Unable to retrieve quota information"); } } } // Test DNSName lookup - Console.WriteLine("Checking DNS names used by this backend..."); + Console.WriteLine(LogTimeStamp + "Checking DNS names used by this backend..."); try { var dnsNames = backend.GetDNSNamesAsync(CancellationToken.None).Await(); @@ -528,12 +540,12 @@ namespace Duplicati.CommandLine.BackendTester } else { - Console.WriteLine("No DNS names reported"); + Console.WriteLine(LogTimeStamp + "No DNS names reported"); } } catch (Exception ex) { - Console.WriteLine("*** Checking DNSName failed: {0}", ex); + Console.WriteLine(LogTimeStamp + "*** Checking DNSName failed: {0}", ex); } } finally @@ -548,7 +560,7 @@ namespace Duplicati.CommandLine.BackendTester private static void Uploadfile(string localfilename, int i, string remotefilename, IBackend backend, bool disableStreaming, long throttle, int readWriteTimeout) { - Console.Write("Uploading file {0}, {1} ... ", i, Duplicati.Library.Utility.Utility.FormatSizeString(new System.IO.FileInfo(localfilename).Length)); + Console.Write(LogTimeStamp + "Uploading file {0}, {1} ... ", i, Duplicati.Library.Utility.Utility.FormatSizeString(new System.IO.FileInfo(localfilename).Length)); Exception e = null; var sw = Stopwatch.StartNew(); @@ -575,11 +587,11 @@ namespace Duplicati.CommandLine.BackendTester if (e != null) { - Console.WriteLine($"Failed to upload file {i}, error message: {e}, remote name: {remotefilename} after {sw.ElapsedMilliseconds} ms"); + Console.WriteLine(LogTimeStamp + $"Failed to upload file {i}, error message: {e}, remote name: {remotefilename} after {sw.ElapsedMilliseconds} ms"); while (e.InnerException != null) { e = e.InnerException; - Console.WriteLine(" Inner exception: {0}", e); + Console.WriteLine(LogTimeStamp + " Inner exception: {0}", e); } } else @@ -604,7 +616,7 @@ namespace Duplicati.CommandLine.BackendTester private static string CreateRandomFile(Library.Utility.TempFolder tf, int i, int min_file_size, int max_file_size, Random rnd) { - Console.Write("Generating file {0}", i); + Console.Write(LogTimeStamp + "Generating file {0}", i); string filename = System.IO.Path.Combine(tf, i.ToString()); using (System.IO.FileStream fs = new System.IO.FileStream(filename, System.IO.FileMode.CreateNew, System.IO.FileAccess.Write)) { @@ -625,6 +637,8 @@ namespace Duplicati.CommandLine.BackendTester return filename; } + private static string LogTimeStamp => $"[{DateTime.Now:HH:mm:ss fff}] "; + public static IList SupportedCommands { get diff --git a/Duplicati/CommandLine/BackendTool/Program.cs b/Duplicati/CommandLine/BackendTool/Program.cs index 0e37d5541..071f27037 100644 --- a/Duplicati/CommandLine/BackendTool/Program.cs +++ b/Duplicati/CommandLine/BackendTool/Program.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Interface; @@ -26,6 +26,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; +using System.Threading.Tasks; namespace Duplicati.CommandLine.BackendTool { @@ -74,7 +75,7 @@ namespace Duplicati.CommandLine.BackendTool } - if (args.Count < 2 || String.Equals(args[0], "help", StringComparison.OrdinalIgnoreCase) || args[0] == "?" || command == null) + if (args.Count < 2 || HelpOptionExtensions.IsArgumentAnyHelpString(args) || command == null) { if (command == null && args.Count >= 2) { @@ -124,7 +125,7 @@ namespace Duplicati.CommandLine.BackendTool throw new UserInformationException(string.Format("too many arguments: {0}", string.Join(",", args)), "BackendToolTooManyArguments"); Console.WriteLine("{0}\t{1}\t{2}\t{3}", "Name", "Dir/File", "LastChange", "Size"); - foreach (var e in backend.List()) + foreach (var e in backend.ListAsync(CancellationToken.None).ToBlockingEnumerable()) Console.WriteLine("{0}\t{1}\t{2}\t{3}", e.Name, e.IsFolder ? "Dir" : "File", e.LastModification, e.Size < 0 ? "" : Library.Utility.Utility.FormatSizeString(e.Size)); return 0; diff --git a/Duplicati/CommandLine/CLI/Commands.cs b/Duplicati/CommandLine/CLI/Commands.cs index a1ae87553..9aa3a8e99 100644 --- a/Duplicati/CommandLine/CLI/Commands.cs +++ b/Duplicati/CommandLine/CLI/Commands.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -163,7 +163,7 @@ namespace Duplicati.CommandLine options.TryGetValue("dbpath", out dbpath); if (string.IsNullOrEmpty(dbpath)) { - dbpath = Library.Main.DatabaseLocator.GetDatabasePath(backend, new Duplicati.Library.Main.Options(options), false, true); + dbpath = Library.Main.CLIDatabaseLocator.GetDatabasePathForCLI(backend, new Library.Main.Options(options), false, true); if (dbpath != null) options["dbpath"] = dbpath; } @@ -366,7 +366,7 @@ namespace Duplicati.CommandLine options.TryGetValue("dbpath", out dbpath); if (string.IsNullOrEmpty(dbpath)) { - dbpath = Library.Main.DatabaseLocator.GetDatabasePath(backend, new Duplicati.Library.Main.Options(options), false, true); + dbpath = Library.Main.CLIDatabaseLocator.GetDatabasePathForCLI(backend, new Duplicati.Library.Main.Options(options), false, true); if (dbpath != null) options["dbpath"] = dbpath; } @@ -622,7 +622,7 @@ namespace Duplicati.CommandLine switch (phase) { case Duplicati.Library.Main.OperationPhase.Backup_ProcessingFiles: - output.MessageEvent("Scanning local files ..."); + output.MessageEvent("Scanning source files ..."); periodicOutput.SetReady(); break; case Duplicati.Library.Main.OperationPhase.Backup_Finalize: @@ -668,6 +668,7 @@ namespace Duplicati.CommandLine if (parsedStats.KnownFileCount > 0) { output.MessageEvent(string.Format(" Remote files: {0}", parsedStats.KnownFileCount)); + output.MessageEvent(string.Format(" Remote filesets: {0}", parsedStats.KnownFilesets)); output.MessageEvent(string.Format(" Remote size: {0}", Library.Utility.Utility.FormatSizeString(parsedStats.KnownFileSize))); } @@ -820,7 +821,7 @@ namespace Duplicati.CommandLine if (string.IsNullOrEmpty(dbpath)) { if (args.Count > 0) - dbpath = Library.Main.DatabaseLocator.GetDatabasePath(args[0], new Duplicati.Library.Main.Options(options), false, true); + dbpath = Library.Main.CLIDatabaseLocator.GetDatabasePathForCLI(args[0], new Duplicati.Library.Main.Options(options), false, true); if (dbpath == null) { @@ -866,7 +867,7 @@ namespace Duplicati.CommandLine options.TryGetValue("dbpath", out dbpath); if (string.IsNullOrEmpty(dbpath)) { - dbpath = Library.Main.DatabaseLocator.GetDatabasePath(args[0], new Duplicati.Library.Main.Options(options), false, true); + dbpath = Library.Main.CLIDatabaseLocator.GetDatabasePathForCLI(args[0], new Duplicati.Library.Main.Options(options), false, true); if (dbpath != null) options["dbpath"] = dbpath; } diff --git a/Duplicati/CommandLine/CLI/Help.cs b/Duplicati/CommandLine/CLI/Help.cs index cd431b0d4..2500a5495 100644 --- a/Duplicati/CommandLine/CLI/Help.cs +++ b/Duplicati/CommandLine/CLI/Help.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -116,7 +116,7 @@ namespace Duplicati.CommandLine Library.Main.Options opts = new Library.Main.Options(new Dictionary()); tp = tp.Replace("%CLI_EXE%", PackageHelper.GetExecutableName(PackageHelper.NamedExecutable.CommandLine)); - tp = tp.Replace("%VERSION%", License.VersionNumbers.Version); + tp = tp.Replace("%VERSION%", License.VersionNumbers.VERSION_NAME); tp = tp.Replace("%BACKENDS%", string.Join(", ", Library.DynamicLoader.BackendLoader.Keys)); tp = tp.Replace("%APP_PATH%", Path.Combine(UpdaterManager.INSTALLATIONDIR, PackageHelper.GetExecutableName(PackageHelper.NamedExecutable.CommandLine))); tp = tp.Replace("%PATH_SEPARATOR%", System.IO.Path.PathSeparator.ToString()); diff --git a/Duplicati/CommandLine/CLI/Program.cs b/Duplicati/CommandLine/CLI/Program.cs index 66e7bb7a0..22b707851 100644 --- a/Duplicati/CommandLine/CLI/Program.cs +++ b/Duplicati/CommandLine/CLI/Program.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -27,11 +27,13 @@ using System.IO; using Duplicati.Library.Interface; using Duplicati.Library.AutoUpdater; using Duplicati.Library.Crashlog; +using Duplicati.Library.Utility; namespace Duplicati.CommandLine { public class Program { + private static readonly string LOGTAG = Library.Logging.Log.LogTagFromType(); public static bool FROM_COMMANDLINE = false; @@ -163,7 +165,7 @@ namespace Duplicati.CommandLine } // Probe for "help" to avoid extra processing - if (cargs.Count == 0 || (string.Equals(cargs[0], "help", StringComparison.OrdinalIgnoreCase))) + if (cargs.Count == 0 || HelpOptionExtensions.IsArgumentAnyHelpString(cargs)) { return Commands.Help(outwriter, setup, cargs, options, filter); } @@ -181,6 +183,10 @@ namespace Duplicati.CommandLine } } + // Don't pass these options into the controller + options.Remove(Library.AutoUpdater.DataFolderManager.SERVER_DATAFOLDER_OPTION); + options.Remove(Library.AutoUpdater.DataFolderManager.PORTABLE_MODE_OPTION); + if (!options.ContainsKey("passphrase")) if (!string.IsNullOrEmpty(System.Environment.GetEnvironmentVariable("PASSPHRASE"))) options["passphrase"] = System.Environment.GetEnvironmentVariable("PASSPHRASE"); @@ -283,20 +289,16 @@ namespace Duplicati.CommandLine } } - public static IList SupportedOptions - { - get - { - return new List(new Library.Interface.ICommandLineArgument[] { - new Library.Interface.CommandLineArgument("parameters-file", Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.ParametersFileOptionShort, Strings.Program.ParametersFileOptionLong2, "", new string[] {"parameter-file", "parameterfile"}), - new Library.Interface.CommandLineArgument("include", Library.Interface.CommandLineArgument.ArgumentType.String, Strings.Program.IncludeShort, Strings.Program.IncludeLong), - new Library.Interface.CommandLineArgument("exclude", Library.Interface.CommandLineArgument.ArgumentType.String, Strings.Program.ExcludeShort, Strings.Program.ExcludeLong), - new Library.Interface.CommandLineArgument("control-files", Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.ControlFilesOptionShort, Strings.Program.ControlFilesOptionLong, "false"), - new Library.Interface.CommandLineArgument("quiet-console", Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.QuietConsoleOptionShort, Strings.Program.QuietConsoleOptionLong, "false"), - new Library.Interface.CommandLineArgument("auto-update", Library.Interface.CommandLineArgument.ArgumentType.Boolean, LC.L("Toggle automatic updates"), LC.L("Set this option if you prefer to have the commandline version automatically update"), "false"), - }); - } - } + public static IList SupportedOptions => [ + new CommandLineArgument("parameters-file", CommandLineArgument.ArgumentType.Path, Strings.Program.ParametersFileOptionShort, Strings.Program.ParametersFileOptionLong2, "", new string[] {"parameter-file", "parameterfile"}), + new CommandLineArgument("include", CommandLineArgument.ArgumentType.String, Strings.Program.IncludeShort, Strings.Program.IncludeLong), + new CommandLineArgument("exclude", CommandLineArgument.ArgumentType.String, Strings.Program.ExcludeShort, Strings.Program.ExcludeLong), + new CommandLineArgument("control-files", CommandLineArgument.ArgumentType.Boolean, Strings.Program.ControlFilesOptionShort, Strings.Program.ControlFilesOptionLong, "false"), + new CommandLineArgument("quiet-console", CommandLineArgument.ArgumentType.Boolean, Strings.Program.QuietConsoleOptionShort, Strings.Program.QuietConsoleOptionLong, "false"), + new CommandLineArgument("auto-update", CommandLineArgument.ArgumentType.Boolean, Strings.Program.AutoUpdateOptionShort, Strings.Program.AutoUpdateOptionLong, "false"), + new CommandLineArgument(DataFolderManager.PORTABLE_MODE_OPTION, CommandLineArgument.ArgumentType.Boolean, Strings.Program.PortableModeOptionShort, Strings.Program.PortableModeOptionLong, DataFolderManager.PORTABLE_MODE.ToString().ToLowerInvariant()), + new CommandLineArgument(DataFolderManager.SERVER_DATAFOLDER_OPTION, CommandLineArgument.ArgumentType.Path, Strings.Program.DataFolderOptionShort, Strings.Program.DataFolderOptionLong, DataFolderManager.DATAFOLDER), + ]; private static bool ReadOptionsFromFile(TextWriter outwriter, string filename, ref Library.Utility.IFilter filter, List cargs, Dictionary options) { diff --git a/Duplicati/CommandLine/CLI/Strings.cs b/Duplicati/CommandLine/CLI/Strings.cs index d7a3a5127..c63e347aa 100644 --- a/Duplicati/CommandLine/CLI/Strings.cs +++ b/Duplicati/CommandLine/CLI/Strings.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Localization.Short; @@ -54,6 +54,12 @@ 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 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"); } } + public static string PortableModeOptionLong { get { return LC.L(@"If this option is set, the configuration files will be stored in a data subfolder of the Duplicati installation folder. This is useful for running from a USB stick or other portable media."); } } + public static string DataFolderOptionShort { get { return LC.L(@"Data folder"); } } + public static string DataFolderOptionLong { get { return LC.L(@"The folder where data is stored. This is the folder where the database and other files are stored."); } } // ReSharper disable once UnusedMember.Global // This is a placeholder message that is intended to be used with the code diff --git a/Duplicati/CommandLine/RecoveryTool/Download.cs b/Duplicati/CommandLine/RecoveryTool/Download.cs index f5cf2d949..21f023ff4 100644 --- a/Duplicati/CommandLine/RecoveryTool/Download.cs +++ b/Duplicati/CommandLine/RecoveryTool/Download.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -25,6 +25,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using Duplicati.Library.Utility; +using System.Threading.Tasks; namespace Duplicati.CommandLine.RecoveryTool { @@ -56,7 +57,7 @@ namespace Duplicati.CommandLine.RecoveryTool Console.WriteLine("Listing files on backend: {0} ...", backend.ProtocolKey); - var lst = backend.List().ToList(); + var lst = backend.ListAsync(CancellationToken.None).ToBlockingEnumerable().ToList(); Console.WriteLine("Found {0} files", lst.Count); diff --git a/Duplicati/CommandLine/RecoveryTool/Recompress.cs b/Duplicati/CommandLine/RecoveryTool/Recompress.cs index ce83d6b75..42a92542e 100644 --- a/Duplicati/CommandLine/RecoveryTool/Recompress.cs +++ b/Duplicati/CommandLine/RecoveryTool/Recompress.cs @@ -29,7 +29,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; -using System.Security.Cryptography; +using System.Threading.Tasks; namespace Duplicati.CommandLine.RecoveryTool { @@ -71,7 +71,7 @@ namespace Duplicati.CommandLine.RecoveryTool Console.WriteLine("Listing files on backend: {0} ...", backend.ProtocolKey); - var rawlist = backend.List().ToList(); + var rawlist = backend.ListAsync(CancellationToken.None).ToBlockingEnumerable().ToList(); Console.WriteLine("Found {0} files at remote storage", rawlist.Count); diff --git a/Duplicati/CommandLine/SecretTool/Program.cs b/Duplicati/CommandLine/SecretTool/Program.cs index 8148b2278..fb59dba26 100644 --- a/Duplicati/CommandLine/SecretTool/Program.cs +++ b/Duplicati/CommandLine/SecretTool/Program.cs @@ -25,6 +25,8 @@ using System.CommandLine.NamingConventionBinder; using System.CommandLine.Parsing; using Duplicati.Library.DynamicLoader; using Duplicati.Library.Interface; +using Duplicati.Library.Utility; +using Uri = System.Uri; namespace Duplicati.CommandLine.SecretTool; @@ -74,7 +76,9 @@ public static class Program Console.WriteLine("Exception: " + ex); context.ExitCode = 1; } - }).Build() + }) + .UseAdditionalHelpAliases() + .Build() .InvokeAsync(args); } diff --git a/Duplicati/CommandLine/ServerUtil/Connection.cs b/Duplicati/CommandLine/ServerUtil/Connection.cs index 4e0d42e97..92d05aee6 100644 --- a/Duplicati/CommandLine/ServerUtil/Connection.cs +++ b/Duplicati/CommandLine/ServerUtil/Connection.cs @@ -21,6 +21,7 @@ using System.Net.Http.Json; using System.Net.Security; using System.Text.Json; +using Duplicati.Library.AutoUpdater; namespace Duplicati.CommandLine.ServerUtil; @@ -185,10 +186,9 @@ public class Connection try { var opts = new Dictionary(); - if (!string.IsNullOrWhiteSpace(settings.ServerDatafolder)) - opts.Add("server-datafolder", settings.ServerDatafolder); - - if (File.Exists(Path.Combine(Server.Program.GetDataFolderPath(opts), Server.Program.SERVER_DATABASE_FILENAME))) + if (settings.Key != null) + opts["settings-encryption-key"] = settings.Key.Key; + if (File.Exists(Path.Combine(DataFolderManager.DATAFOLDER, DataFolderManager.SERVER_DATABASE_FILENAME))) { string? cfg = null; using (var connection = Duplicati.Server.Program.GetDatabaseConnection(opts, true)) @@ -220,9 +220,9 @@ public class Connection return CreateConnectionWithClient(client, accessToken); } } - else if (!string.IsNullOrWhiteSpace(settings.ServerDatafolder)) + else if (!string.IsNullOrWhiteSpace(DataFolderManager.DATAFOLDER)) { - Console.WriteLine($"No database found in {settings.ServerDatafolder}"); + Console.WriteLine($"No database found in {DataFolderManager.DATAFOLDER}"); } } catch (Exception ex) diff --git a/Duplicati/CommandLine/ServerUtil/Program.cs b/Duplicati/CommandLine/ServerUtil/Program.cs index 8485fbc8d..2ba212806 100644 --- a/Duplicati/CommandLine/ServerUtil/Program.cs +++ b/Duplicati/CommandLine/ServerUtil/Program.cs @@ -22,6 +22,7 @@ using System.CommandLine; using System.CommandLine.Builder; using System.CommandLine.Parsing; using Duplicati.CommandLine.ServerUtil.Commands; +using Duplicati.Library.Utility; namespace Duplicati.CommandLine.ServerUtil; @@ -80,6 +81,7 @@ public static class Program await next(context); }) + .UseAdditionalHelpAliases() .Build() .InvokeAsync(args); } diff --git a/Duplicati/CommandLine/ServerUtil/Settings.cs b/Duplicati/CommandLine/ServerUtil/Settings.cs index 6859cad45..1771f524c 100644 --- a/Duplicati/CommandLine/ServerUtil/Settings.cs +++ b/Duplicati/CommandLine/ServerUtil/Settings.cs @@ -19,11 +19,13 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System.Text.Json; +using Duplicati.Library.AutoUpdater; using Duplicati.Library.DynamicLoader; using Duplicati.Library.Encryption; using Duplicati.Library.Interface; using Duplicati.Library.Main; using Duplicati.Library.Utility; +using Utility = Duplicati.Library.Main.Utility; namespace Duplicati.CommandLine.ServerUtil; @@ -33,7 +35,6 @@ namespace Duplicati.CommandLine.ServerUtil; /// The commandline password /// The saved refresh token /// The host url to connect to -/// The server datafolder for password-free connections /// The settings file where data is loaded/saved /// Whether to disable TLS/SSL certificate trust check /// The encryption key to use for the settings file @@ -44,7 +45,6 @@ public sealed record Settings( string? Password, string? RefreshToken, System.Uri HostUrl, - string? ServerDatafolder, string SettingsFile, bool Insecure, EncryptedFieldHelper.KeyInstance? Key, @@ -64,21 +64,21 @@ public sealed record Settings( System.Uri HostUrl, string? ServerDatafolder ); - private static string GetDefaultStorageFolder(string filename) - { - var folder = DatabaseLocator.GetDefaultStorageFolderWithDebugSupport(filename); - if (!Directory.Exists(folder)) - Directory.CreateDirectory(folder); - return folder; - } + /// + /// Gets the default data folder for the settings file + /// + /// The filename to use + /// The default storage folder + private static string GetDefaultStorageFolder(string filename) + // Ideally, this should use DataFolderManager.DATAFOLDER, but we cannot due to backwards compatibility + => DataFolderLocator.GetDefaultStorageFolder(filename, true); /// /// Loads the settings from the settings file /// /// The password to use /// The host URL to use - /// The server data folder to use /// The settings file to use /// Whether to disable TLS/SSL certificate trust check /// The encryption key to use @@ -87,9 +87,9 @@ public sealed record Settings( /// The secret provider pattern to use /// The SHA1 hash of the host certificate to accept /// The loaded settings - public static Settings Load(string? password, System.Uri? hostUrl, string? serverDataFolder, string settingsFile, bool insecure, string? settingsPassphrase, string? secretProvider, SecretProviderHelper.CachingLevel secretProviderCache, string secretProviderPattern, string? acceptedHostCertificate) + public static Settings Load(string? password, System.Uri? hostUrl, string settingsFile, bool insecure, string? settingsPassphrase, string? secretProvider, SecretProviderHelper.CachingLevel secretProviderCache, string secretProviderPattern, string? acceptedHostCertificate) { - hostUrl ??= new System.Uri("http://localhost:8200"); + hostUrl ??= new System.Uri($"http://{Library.Utility.Utility.IpVersionCompatibleLoopback}:8200"); ISecretProvider? secretInstance = null; if (!string.IsNullOrWhiteSpace(secretProvider)) @@ -115,9 +115,6 @@ public sealed record Settings( settingsPassphrase = opts["settings-encryption-key"]; } - if (string.IsNullOrWhiteSpace(serverDataFolder)) - serverDataFolder = GetDefaultStorageFolder("Duplicati-server.sqlite"); - if (!string.IsNullOrWhiteSpace(settingsFile) && !Path.IsPathRooted(settingsFile)) settingsFile = Path.Combine(GetDefaultStorageFolder(settingsFile), settingsFile); @@ -129,7 +126,6 @@ public sealed record Settings( password, persistedSettings?.RefreshToken, hostUrl, - serverDataFolder, settingsFile, insecure, key, @@ -175,7 +171,7 @@ public sealed record Settings( File.WriteAllText(SettingsFile, JsonSerializer.Serialize(LoadSettings(SettingsFile, thisKey) .Where(x => x.HostUrl != HostUrl) - .Append(new PersistedSettings(RefreshToken, HostUrl, ServerDatafolder)) + .Append(new PersistedSettings(RefreshToken, HostUrl, DataFolderManager.DATAFOLDER)) .Select(x => x with { RefreshToken = string.IsNullOrWhiteSpace(x.RefreshToken) || thisKey == null diff --git a/Duplicati/CommandLine/ServerUtil/SettingsBinder.cs b/Duplicati/CommandLine/ServerUtil/SettingsBinder.cs index fb72f2760..b97b7378b 100644 --- a/Duplicati/CommandLine/ServerUtil/SettingsBinder.cs +++ b/Duplicati/CommandLine/ServerUtil/SettingsBinder.cs @@ -20,6 +20,7 @@ // DEALINGS IN THE SOFTWARE. using System.CommandLine; using System.CommandLine.Binding; +using Duplicati.Library.AutoUpdater; using Duplicati.Library.Main; namespace Duplicati.CommandLine.ServerUtil; @@ -36,11 +37,15 @@ public class SettingsBinder : BinderBase /// /// The host URL option. /// - public static readonly Option hostUrlOption = new Option("--hosturl", description: "The host URL to use", getDefaultValue: () => new Uri("http://localhost:8200")); + public static readonly Option hostUrlOption = new Option("--hosturl", description: "The host URL to use", getDefaultValue: () => new Uri($"http://{Library.Utility.Utility.IpVersionCompatibleLoopback}:8200")); /// /// The server datafolder option. /// - public static readonly Option serverDatafolderOption = new Option("--server-datafolder", description: "The server datafolder to use for locating the database", getDefaultValue: () => null); + public static readonly Option serverDatafolderOption = new Option($"--{DataFolderManager.SERVER_DATAFOLDER_OPTION}", description: "The server datafolder to use for locating the database and storing configuration", getDefaultValue: () => new DirectoryInfo(DataFolderManager.DATAFOLDER)); + /// + /// The server portable mode option. + /// + public static readonly Option portableModeOption = new Option($"--{DataFolderManager.PORTABLE_MODE_OPTION}", description: "Use portable mode for locating the database and storing configuration", getDefaultValue: () => DataFolderManager.PORTABLE_MODE); /// /// The settings file option. /// @@ -84,6 +89,7 @@ public class SettingsBinder : BinderBase rootCommand.AddGlobalOption(passwordOption); rootCommand.AddGlobalOption(hostUrlOption); rootCommand.AddGlobalOption(serverDatafolderOption); + rootCommand.AddGlobalOption(portableModeOption); rootCommand.AddGlobalOption(settingsFileOption); rootCommand.AddGlobalOption(insecureOption); rootCommand.AddGlobalOption(settingsEncryptionKeyOption); @@ -103,7 +109,6 @@ public class SettingsBinder : BinderBase Settings.Load( bindingContext.ParseResult.GetValueForOption(passwordOption), bindingContext.ParseResult.GetValueForOption(hostUrlOption), - bindingContext.ParseResult.GetValueForOption(serverDatafolderOption)?.FullName, bindingContext.ParseResult.GetValueForOption(settingsFileOption)?.FullName ?? "settings.json", bindingContext.ParseResult.GetValueForOption(insecureOption), bindingContext.ParseResult.GetValueForOption(settingsEncryptionKeyOption) ?? Environment.GetEnvironmentVariable(Library.Encryption.EncryptedFieldHelper.ENVIROMENT_VARIABLE_NAME), diff --git a/Duplicati/CommandLine/SourceTool/CommandExtensions.cs b/Duplicati/CommandLine/SourceTool/CommandExtensions.cs new file mode 100644 index 000000000..019672143 --- /dev/null +++ b/Duplicati/CommandLine/SourceTool/CommandExtensions.cs @@ -0,0 +1,42 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. +using System.CommandLine; +using System.CommandLine.Invocation; + +namespace Duplicati.CommandLine.SourceTool; + +/// +/// Extensions for . +/// +public static class CommandExtensions +{ + /// + /// Adds the missing WithHandler method to . + /// + /// The command to add the handler to. + /// The handler to add. + /// The command with the handler added. + public static Command WithHandler(this Command command, ICommandHandler handler) + { + command.Handler = handler; + return command; + } +} diff --git a/Duplicati/CommandLine/SourceTool/Commands/Download.cs b/Duplicati/CommandLine/SourceTool/Commands/Download.cs new file mode 100644 index 000000000..0297a2ae3 --- /dev/null +++ b/Duplicati/CommandLine/SourceTool/Commands/Download.cs @@ -0,0 +1,110 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. +using System.CommandLine; +using System.CommandLine.NamingConventionBinder; + +namespace Duplicati.CommandLine.SourceTool.Commands; + +/// +/// The download command +/// +public static class Download +{ + /// + /// Creates the download command + /// + /// The download command + public static Command Create() => + new Command("download", "Downloads all files from the remote") + { + new Argument("url", "The source URL") { + Arity = ArgumentArity.ExactlyOne + }, + new Option("--destination", description: "The destination folder", getDefaultValue: () => new DirectoryInfo(Directory.GetCurrentDirectory())), + new Option("--max-depth", description: "The maximum depth to visit", getDefaultValue: () => 0), + new Option("--max-size", description: "The maximum filesize to download", getDefaultValue: () => 0), + new Option("--overwrite", description: "Overwrite existing files", getDefaultValue: () => false), + } + .WithHandler(CommandHandler.Create(async (url, destination, maxdepth, maxsize, overwrite) => + { + var token = new CancellationTokenSource().Token; + + using var source = await Common.GetProvider(url); + + string localPath(string path) + { + var relpath = path.Substring(source.MountedPath.Length).TrimStart(Path.DirectorySeparatorChar); + return Path.Combine(destination.FullName, relpath); + } + + var started = DateTime.UtcNow; + var fileCount = 0L; + var folderCount = 0L; + var totalSize = 0L; + var downloadCount = 0L; + var downloadSize = 0L; + + await Common.Visit(source, maxdepth, async (entry, level) => + { + if (entry.IsMetaEntry) + return true; + + var path = localPath(entry.Path); + if (entry.IsFolder) + { + folderCount++; + if (!Directory.Exists(path)) + Directory.CreateDirectory(path); + } + else + { + fileCount++; + totalSize += entry.Size; + + if (entry.Size <= maxsize || maxsize <= 0) + { + var folder = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(folder) && !Directory.Exists(folder)) + Directory.CreateDirectory(folder); + + if (!overwrite && File.Exists(path)) + { + Console.WriteLine($"Skipping {entry.Path} as it already exists"); + } + else + { + downloadCount++; + downloadSize += entry.Size; + Console.WriteLine($"Downloading {entry.Path} to {path} ({Library.Utility.Utility.FormatSizeString(entry.Size)})"); + await using var stream = await entry.OpenRead(token); + await using var dest = File.OpenWrite(path); + await stream.CopyToAsync(dest, token); + } + } + } + + return true; + }, token); + + Console.WriteLine($"Found {fileCount} files and {folderCount} folders with a total size of {Library.Utility.Utility.FormatSizeString(totalSize)}"); + Console.WriteLine($"Downloaded {downloadCount} files with a total size of {Library.Utility.Utility.FormatSizeString(downloadSize)} in {DateTime.UtcNow - started}"); + })); +} diff --git a/Duplicati/CommandLine/SourceTool/Commands/List.cs b/Duplicati/CommandLine/SourceTool/Commands/List.cs new file mode 100644 index 000000000..f5ceca3fb --- /dev/null +++ b/Duplicati/CommandLine/SourceTool/Commands/List.cs @@ -0,0 +1,67 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. +using System.CommandLine; +using System.CommandLine.NamingConventionBinder; + +namespace Duplicati.CommandLine.SourceTool.Commands; + +/// +/// The list command +/// +public static class List +{ + /// + /// Creates the list command + /// + /// The command + public static Command Create() => + new Command("list", "Lists all paths on the remote") + { + new Argument("url", "The source URL") { + Arity = ArgumentArity.ExactlyOne + }, + new Option("--max-depth", description: "The maximum depth to list", getDefaultValue: () => 0), + } + .WithHandler(CommandHandler.Create(async (url, maxdepth) => + { + var token = new CancellationTokenSource().Token; + var folders = 0L; + var files = 0L; + + using var source = await Common.GetProvider(url); + await Common.Visit(source, maxdepth, (entry, level) => + { + if (entry.IsFolder) + folders++; + else + files++; + if (entry.IsFolder && level > 0) + level--; + if (!entry.IsRootEntry) + Console.WriteLine($"{new string(' ', (level + 1) * 2)}{entry.Path}"); + return Task.FromResult(true); + }, token); + + Console.WriteLine($"Found {folders} folders and {files} files"); + }) + ); + +} diff --git a/Duplicati/CommandLine/SourceTool/Duplicati.CommandLine.SourceTool.csproj b/Duplicati/CommandLine/SourceTool/Duplicati.CommandLine.SourceTool.csproj new file mode 100644 index 000000000..99c103755 --- /dev/null +++ b/Duplicati/CommandLine/SourceTool/Duplicati.CommandLine.SourceTool.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + Duplicati.CommandLine.SourceTool.Implementation + Copyright © 2025 Team Duplicati, MIT license + Duplicati.CommandLine.Sourcetool + + + + + + + + + + + + + + + + diff --git a/Duplicati/CommandLine/SourceTool/GetProvider.cs b/Duplicati/CommandLine/SourceTool/GetProvider.cs new file mode 100644 index 000000000..f4637e398 --- /dev/null +++ b/Duplicati/CommandLine/SourceTool/GetProvider.cs @@ -0,0 +1,39 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. +using Duplicati.Library.DynamicLoader; +using Duplicati.Library.Interface; + +namespace Duplicati.CommandLine.SourceTool; + +/// +/// Common methods for the source tool +/// +public static partial class Common +{ + /// + /// Gets the source provider for the given URL + /// + /// The URL to get the provider for + /// The source provider + public static async Task GetProvider(string url) => + (await SourceProviderLoader.GetSourceProvider(url, "", [], CancellationToken.None)) + ?? throw new UserInformationException(string.Format("The source \"{0}\" is not supported", url), "SourceNotSupported"); +} \ No newline at end of file diff --git a/Duplicati/CommandLine/SourceTool/ListEnumerator.cs b/Duplicati/CommandLine/SourceTool/ListEnumerator.cs new file mode 100644 index 000000000..5c6ca8845 --- /dev/null +++ b/Duplicati/CommandLine/SourceTool/ListEnumerator.cs @@ -0,0 +1,60 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. +using Duplicati.Library.Interface; + +namespace Duplicati.CommandLine.SourceTool; + +/// +/// Common methods for the source tool +/// +public static partial class Common +{ + /// + /// Visits all entries in the source provider + /// + /// The source provider to visit + /// The maximum depth to visit + /// The visitor function + /// The cancellation token + /// An awaitable task + public static async Task Visit(ISourceProvider source, int maxdepth, Func> visitor, CancellationToken token) + { + var visit = new Stack<(ISourceProviderEntry Entry, int Level)>(); + await foreach (var item in source.Enumerate(token)) + visit.Push((item, 0)); + + while (visit.Count() != 0) + { + var item = visit.Pop(); + var process = await visitor(item.Entry, item.Level); + if (item.Entry.IsFolder && process && (item.Level < maxdepth || maxdepth <= 0)) + { + await foreach (var subitem in item.Entry.Enumerate(token)) + { + if (subitem.IsFolder) + visit.Push((subitem, item.Level + 1)); + else + await visitor(subitem, item.Level); + } + } + } + } +} diff --git a/Duplicati/CommandLine/SourceTool/Program.cs b/Duplicati/CommandLine/SourceTool/Program.cs new file mode 100644 index 000000000..c8aea4225 --- /dev/null +++ b/Duplicati/CommandLine/SourceTool/Program.cs @@ -0,0 +1,68 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. +using System.CommandLine; +using System.CommandLine.Builder; +using System.CommandLine.Parsing; +using Duplicati.Library.Interface; +using Duplicati.Library.Utility; + +namespace Duplicati.CommandLine.SourceTool; + +/// +/// The entry point of the application +/// +public static class Program +{ + /// + /// Executes the program + /// + /// The arguments + /// The return code + public static Task Main(string[] args) + { + Library.AutoUpdater.PreloadSettingsLoader.ConfigurePreloadSettings(ref args, Library.AutoUpdater.PackageHelper.NamedExecutable.ServerUtil); + + var rootCmd = new RootCommand("Source CLI tool for Duplicati") + { + Commands.List.Create(), + Commands.Download.Create(), + }; + + return new CommandLineBuilder(rootCmd) + .UseDefaults() + .UseExceptionHandler((ex, context) => + { + if (ex is UserInformationException uie) + { + Console.WriteLine(uie.Message); + context.ExitCode = 2; + } + else + { + Console.WriteLine(ex.ToString()); + context.ExitCode = 1; + } + }) + .UseAdditionalHelpAliases() + .Build() + .InvokeAsync(args); + } +} diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/linux/normal-disconnected.png b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/linux/normal-disconnected.png new file mode 100644 index 000000000..1c9813b2d Binary files /dev/null and b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/linux/normal-disconnected.png differ diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/dark/normal-disconnected.png b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/dark/normal-disconnected.png new file mode 100644 index 000000000..1f29949ed Binary files /dev/null and b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/dark/normal-disconnected.png differ diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/light/normal-disconnected.png b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/light/normal-disconnected.png new file mode 100644 index 000000000..f74fbf15e Binary files /dev/null and b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/light/normal-disconnected.png differ diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/windows/normal-disconnected.png b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/windows/normal-disconnected.png new file mode 100644 index 000000000..d2260393b Binary files /dev/null and b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/windows/normal-disconnected.png differ diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/AvaloniaRunner.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/AvaloniaRunner.cs index b77cf1ba7..e10821d74 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/AvaloniaRunner.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/AvaloniaRunner.cs @@ -19,6 +19,8 @@ // 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; @@ -40,7 +42,7 @@ namespace Duplicati.GUI.TrayIcon public class AvaloniaRunner : TrayIconBase { private static readonly string LOGTAG = Log.LogTagFromType(); - private AvaloniaApp application; + private AvaloniaApp? application; private ProcessBasedActionDelay actionDelayer = new ProcessBasedActionDelay(); private IEnumerable menuItems = Enumerable.Empty(); @@ -106,6 +108,8 @@ namespace Duplicati.GUI.TrayIcon #endif application = builder.Instance as AvaloniaApp; + if (application == null) + throw new InvalidOperationException("Failed to create Avalonia app"); application.SetMenu(menuItems); application.Configure(); @@ -114,7 +118,7 @@ namespace Duplicati.GUI.TrayIcon private async void CheckForAppInitialized(IClassicDesktopStyleApplicationLifetime lifetime) { - Exception lastEx = null; + Exception? lastEx = null; // Jump out of the UI thread, ot ensure that the check is done with posting to the UI thread, // as opposed to just calling the method directly @@ -164,7 +168,7 @@ namespace Duplicati.GUI.TrayIcon return new AvaloniaMenuItem(this, text, icon, callback, subitems); } - protected override void NotifyUser(string title, string message, NotificationType type) + public override void NotifyUser(string title, string message, NotificationType type) { //var icon = Win32NativeNotifyIcon.InfoFlags.NIIF_INFO; @@ -180,8 +184,6 @@ namespace Duplicati.GUI.TrayIcon //icon = Win32NativeNotifyIcon.InfoFlags.NIIF_ERROR; break; } - - //m_ntfIcon.ShowBalloonTip(title, message, icon); } protected override void Exit() @@ -209,7 +211,7 @@ namespace Duplicati.GUI.TrayIcon internal static string GetThemePath() { - var isDark = string.Equals(Application.Current.ActualThemeVariant.ToString(), "Dark", StringComparison.OrdinalIgnoreCase); + var isDark = string.Equals(Application.Current?.ActualThemeVariant.ToString(), "Dark", StringComparison.OrdinalIgnoreCase); if (OperatingSystem.IsMacOS()) return isDark ? "macos/dark" : "macos/light"; @@ -237,11 +239,12 @@ namespace Duplicati.GUI.TrayIcon public string Text { get; private set; } public Action Callback { get; private set; } - public IList SubItems { get; private set; } + public IList? SubItems { get; private set; } public bool Enabled { get; private set; } public MenuIcons Icon { get; private set; } public bool IsDefault { get; private set; } - private NativeMenuItem nativeMenuItem; + public bool Hidden { get; private set; } + private NativeMenuItem? nativeMenuItem; private readonly AvaloniaRunner parent; public AvaloniaMenuItem(AvaloniaRunner parent, string text, MenuIcons icon, Action callback, IList subitems) @@ -278,6 +281,9 @@ namespace Duplicati.GUI.TrayIcon /// private void UpdateIcon() { + if (nativeMenuItem == null) + return; + nativeMenuItem.Icon = this.Icon switch { MenuIcons.Status => AvaloniaRunner.LoadBitmap("context-menu-open.png"), @@ -301,6 +307,14 @@ namespace Duplicati.GUI.TrayIcon // Not currently supported by Avalonia, // used to set the menu bold on Windows to indicate the default entry } + + public void SetHidden(bool hidden) + { + this.Hidden = hidden; + if (nativeMenuItem != null) + parent.RunOnUIThread(() => nativeMenuItem.IsVisible = !this.Hidden); + + } #endregion public NativeMenuItem GetNativeItem() @@ -324,9 +338,9 @@ namespace Duplicati.GUI.TrayIcon public class AvaloniaApp : Application { - private Avalonia.Controls.TrayIcon trayIcon; + private Avalonia.Controls.TrayIcon? trayIcon; - private List menuItems; + private List? menuItems; public override void Initialize() { @@ -343,6 +357,9 @@ namespace Duplicati.GUI.TrayIcon case TrayIcons.IdleError: this.trayIcon.Icon = AvaloniaRunner.LoadIcon("normal-error.png"); break; + case TrayIcons.IdleWarning: + this.trayIcon.Icon = AvaloniaRunner.LoadIcon("normal-warning.png"); + break; case TrayIcons.Paused: case TrayIcons.PausedError: this.trayIcon.Icon = AvaloniaRunner.LoadIcon("normal-pause.png"); @@ -355,13 +372,17 @@ namespace Duplicati.GUI.TrayIcon default: this.trayIcon.Icon = AvaloniaRunner.LoadIcon("normal.png"); break; + case TrayIcons.Disconnected: + this.trayIcon.Icon = AvaloniaRunner.LoadIcon("normal-disconnected.png"); + break; } } public void Configure() { this.Name = Duplicati.Library.AutoUpdater.AutoUpdateSettings.AppName; - this.trayIcon.ToolTipText = this.Name; + if (this.trayIcon != null) + this.trayIcon.ToolTipText = this.Name; } public void SetMenu(IEnumerable menuItems) @@ -377,7 +398,18 @@ namespace Duplicati.GUI.TrayIcon menu.Add(item.GetNativeItem()); } trayIcon.Menu = menu; - trayIcon.Clicked += (_, _) => this.menuItems.FirstOrDefault(x => x.IsDefault)?.Callback(); + trayIcon.Clicked -= HandleTrayIconClick; + trayIcon.Clicked += HandleTrayIconClick; + } + } + + private readonly ClickDebouncer _clickDebouncer = new ClickDebouncer(); + + private void HandleTrayIconClick(object? sender, EventArgs e) + { + if (_clickDebouncer.ShouldProcessClick()) + { + this.menuItems?.FirstOrDefault(x => x.IsDefault)?.Callback(); } } @@ -413,10 +445,10 @@ namespace Duplicati.GUI.TrayIcon public bool IsEnabled(LogEventLevel level, string area) => level >= _minLevel; - public void Log(LogEventLevel level, string area, object source, string messageTemplate) - => Log(level, area, source, messageTemplate, null); + public void Log(LogEventLevel level, string area, object? source, string messageTemplate) + => Log(level, area, source, messageTemplate, []); - public void Log(LogEventLevel level, string area, object source, string messageTemplate, params object[] propertyValues) + public void Log(LogEventLevel level, string area, object? source, string messageTemplate, params object?[] propertyValues) => Console.WriteLine($"Avalonia [{level}]: {source} {messageTemplate} {string.Join(" ", propertyValues)}"); } diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/ClickDebouncer.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/ClickDebouncer.cs new file mode 100644 index 000000000..e3f012c86 --- /dev/null +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/ClickDebouncer.cs @@ -0,0 +1,27 @@ +using System; + +namespace Duplicati.GUI.TrayIcon +{ + internal class ClickDebouncer + { + private DateTime _lastClickTime = DateTime.MinValue; + private readonly TimeSpan _debounceInterval; + + public ClickDebouncer(TimeSpan? debounceInterval = null) + { + _debounceInterval = debounceInterval ?? TimeSpan.FromSeconds(1); + } + + public bool ShouldProcessClick() + { + var now = DateTime.UtcNow; + if (now - _lastClickTime < _debounceInterval) + { + return false; + } + + _lastClickTime = now; + return true; + } + } +} \ No newline at end of file diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/HttpServerConnection.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/HttpServerConnection.cs index cf610569a..481744afc 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/HttpServerConnection.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/HttpServerConnection.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -88,6 +88,7 @@ namespace Duplicati.GUI.TrayIcon private readonly string m_baseUri; private string m_password; private string m_accesstoken; + private bool m_isTryingWithPassword; public delegate void StatusUpdateDelegate(IServerStatus status); public event StatusUpdateDelegate OnStatusUpdated; @@ -161,10 +162,6 @@ namespace Duplicati.GUI.TrayIcon } }; - // TODO: Not nice to do in constructor - // Get a connection - UpdateStatus(false); - m_waitLock = new AutoResetEvent(false); m_requestThread = new Thread(ThreadRunner); m_pollThread = new Thread(LongPollRunner); @@ -176,11 +173,14 @@ namespace Duplicati.GUI.TrayIcon m_pollThread.Start(); } - private void UpdateStatus(bool longpoll) + public Task UpdateStatus() + => UpdateStatusAsync(false); + + private async Task UpdateStatusAsync(bool longpoll) { var query = longpoll ? $"?longpoll=true&lastEventId={m_lastEventId}&duration={LONGPOLL_TIMEOUT}" : ""; - m_status = PerformRequest("GET", $"/serverstate{query}", null, longpoll ? Library.Utility.Timeparser.ParseTimeSpan(LONGPOLL_TIMEOUT) : null); + m_status = await PerformRequestAsync("GET", $"/serverstate{query}", null, longpoll ? Library.Utility.Timeparser.ParseTimeSpan(LONGPOLL_TIMEOUT) : null); m_lastEventId = m_status.LastEventID; if (OnStatusUpdated != null) @@ -189,19 +189,19 @@ namespace Duplicati.GUI.TrayIcon if (m_lastNotificationId != m_status.LastNotificationUpdateID) { m_lastNotificationId = m_status.LastNotificationUpdateID; - UpdateNotifications(); + await UpdateNotificationsAsync().ConfigureAwait(false); } if (m_lastDataUpdateId != m_status.LastDataUpdateID) { m_lastDataUpdateId = m_status.LastDataUpdateID; - UpdateApplicationSettings(); + await UpdateApplicationSettingsAsync().ConfigureAwait(false); } } - private void UpdateNotifications() + private async Task UpdateNotificationsAsync() { - var notifications = PerformRequest("GET", "/notifications", null, null); + var notifications = await PerformRequestAsync("GET", "/notifications", null, null).ConfigureAwait(false); if (notifications != null) { foreach (var n in notifications.Where(x => x.Timestamp > m_firstNotificationTime)) @@ -213,9 +213,9 @@ namespace Duplicati.GUI.TrayIcon } } - private void UpdateApplicationSettings() + private async Task UpdateApplicationSettingsAsync() { - var settings = PerformRequest>("GET", "/serversettings", null, null); + var settings = await PerformRequestAsync>("GET", "/serversettings", null, null).ConfigureAwait(false); if (settings != null && settings.TryGetValue("disable-tray-icon-login", out var str)) m_disableTrayIconLogin = Library.Utility.Utility.ParseBool(str, false); } @@ -235,12 +235,15 @@ namespace Duplicati.GUI.TrayIcon if (waitTime.TotalSeconds > 0) Thread.Sleep(waitTime); started = DateTime.Now; - UpdateStatus(true); + UpdateStatusAsync(true).Await(); errorCount = 0; } catch (Exception ex) { errorCount++; + // More than 1 error, and we are disconnected + if (errorCount >= 2) + Task.Run(() => { OnStatusUpdated?.Invoke(new ServerStatusImpl(LiveControlState.Paused, SuggestedStatusIcon.Disconnected, m_lastEventId, m_lastDataUpdateId, m_lastNotificationId)); }).Await(); System.Diagnostics.Trace.WriteLine("Request error: " + ex.Message); Library.Logging.Log.WriteWarningMessage(LOGTAG, "TrayIconRequestError", ex, "Failed to get response"); } @@ -269,7 +272,7 @@ namespace Duplicati.GUI.TrayIcon if (req != null) { any = true; - PerformRequest(req.Method, req.Endpoint, req.Body, req.Timeout); + PerformRequestAsync(req.Method, req.Endpoint, req.Body, req.Timeout).Await(); } } while (req != null); @@ -300,18 +303,18 @@ namespace Duplicati.GUI.TrayIcon private sealed record SigninResponse(string AccessToken); - private T PerformRequest(string method, string urlfragment, string body, TimeSpan? timeout) + private async Task PerformRequestAsync(string method, string urlfragment, string body, TimeSpan? timeout) { if (string.IsNullOrWhiteSpace(m_accesstoken) && !urlfragment.StartsWith("/auth/")) - ObtainAccessToken(); + await ObtainAccessTokenAsync().ConfigureAwait(false); - var hasTriedPassword = false; + var hasTriedPassword = m_isTryingWithPassword; while (true) { try { - return PerformRequestInternal(method, urlfragment, body, timeout); + return await PerformRequestInternalAsync(method, urlfragment, body, timeout).ConfigureAwait(false); } catch (Exception ex) when (ex is HttpRequestException hex && (hex.StatusCode == HttpStatusCode.Unauthorized || hex.StatusCode == HttpStatusCode.Forbidden) @@ -321,20 +324,49 @@ namespace Duplicati.GUI.TrayIcon if (hasTriedPassword) throw; + // TODO: This error handling is error prone and can end up in infinite recursion + // Should rewrite the entire class and use websockets instead + // Only try once, and clear the token for the next try hasTriedPassword = true; m_accesstoken = null; - ObtainAccessToken(); + try + { + m_isTryingWithPassword = true; + await ObtainAccessTokenAsync().ConfigureAwait(false); + } + finally + { + m_isTryingWithPassword = false; + } } } } - private void ObtainAccessToken() + private async Task ObtainAccessTokenAsync() { - var token = ObtainSignInToken(); + // If we host the server, issue the access token from the service + if (FIXMEGlobal.IsServerStarted && m_passwordSource == Program.PasswordSource.HostedServer) + { + var provider = FIXMEGlobal.Provider.GetRequiredService(); + m_accesstoken = provider.CreateAccessToken("trayicon", provider.TemporaryFamilyId); + return; + } + + // If we know the password, issue a token from the API + if (!string.IsNullOrWhiteSpace(m_password)) + { + m_accesstoken = (await PerformRequestInternalAsync("POST", "/auth/login", JsonSerializer.Serialize(new { Password = m_password }), null).ConfigureAwait(false)).AccessToken; + return; + } + + // Otherwise, try to get a signin token + var token = await ObtainSignInTokenAsync().ConfigureAwait(false); if (string.IsNullOrWhiteSpace(token)) return; - m_accesstoken = PerformRequestInternal("POST", "/auth/signin", JsonSerializer.Serialize(new { SigninToken = token }), null).AccessToken; + + // Use the signin token to get an access token + m_accesstoken = (await PerformRequestInternalAsync("POST", "/auth/signin", JsonSerializer.Serialize(new { SigninToken = token }), null).ConfigureAwait(false)).AccessToken; } private async Task PerformRequestInternalAsync(string method, string endpoint, string body, TimeSpan? timeout) @@ -358,13 +390,10 @@ namespace Duplicati.GUI.TrayIcon if (typeof(T) == typeof(string)) return (T)(object)await response.Content.ReadAsStringAsync().ConfigureAwait(false); - using (var stream = await response.Content.ReadAsStreamAsync()) + using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) return await JsonSerializer.DeserializeAsync(stream, serializerOptions).ConfigureAwait(false); } - private T PerformRequestInternal(string method, string endpoint, string body, TimeSpan? timeout) - => PerformRequestInternalAsync(method, endpoint, body, timeout).Await(); - private void ExecuteAndNotify(string method, string urifragment, string body) { lock (m_lock) @@ -391,21 +420,29 @@ namespace Duplicati.GUI.TrayIcon } private sealed record SigninTokenResponse(string Token); - private string IssueSigninToken(string password) - => PerformRequest("POST", "/auth/issuesignintoken", JsonSerializer.Serialize(new { Password = password }), null).Token; + private async Task IssueSigninTokenAsync(string password) + => (await PerformRequestInternalAsync("POST", "/auth/issuesignintoken", JsonSerializer.Serialize(new { Password = password }), null).ConfigureAwait(false)).Token; - private string ObtainSignInToken() + private async Task ObtainSignInTokenAsync() { string signinjwt = null; // If we host the server, issue the token from the service if (FIXMEGlobal.IsServerStarted && m_passwordSource == Program.PasswordSource.HostedServer) + { + if (FIXMEGlobal.DataConnection.ApplicationSettings.DisableSigninTokens) + return null; + signinjwt = FIXMEGlobal.Provider.GetRequiredService().CreateSigninToken("trayicon"); + } // If we have database access, grab the issuer key from the db and issue a token if (string.IsNullOrWhiteSpace(signinjwt) && m_passwordSource == Program.PasswordSource.Database) { Program.databaseConnection.ApplicationSettings.ReloadSettings(); + if (Program.databaseConnection.ApplicationSettings.DisableSigninTokens) + return null; + var cfg = Program.databaseConnection.ApplicationSettings.JWTConfig; if (!string.IsNullOrWhiteSpace(cfg)) signinjwt = new JWTTokenProvider(JsonSerializer.Deserialize(cfg)).CreateSigninToken("trayicon"); @@ -413,20 +450,28 @@ namespace Duplicati.GUI.TrayIcon // If we know the password, issue a token from the API if (string.IsNullOrWhiteSpace(signinjwt) && !string.IsNullOrWhiteSpace(m_password)) - signinjwt = IssueSigninToken(m_password); + signinjwt = await IssueSigninTokenAsync(m_password); return signinjwt; } - public string StatusWindowURL + public async Task GetStatusWindowURLAsync() { - get + string signinjwt = null; + if (!m_disableTrayIconLogin) { - var signinjwt = m_disableTrayIconLogin ? null : ObtainSignInToken(); - return string.IsNullOrWhiteSpace(signinjwt) - ? m_baseUri + STATUS_WINDOW - : m_baseUri + SIGNIN_WINDOW + $"?token={signinjwt}"; + try + { + signinjwt = await ObtainSignInTokenAsync().ConfigureAwait(false); + } + catch + { + } } + + return string.IsNullOrWhiteSpace(signinjwt) + ? m_baseUri + STATUS_WINDOW + : m_baseUri + SIGNIN_WINDOW + $"?token={signinjwt}"; } } } diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs index de14695d5..c1cc97bdd 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -24,9 +24,14 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; +using System.Net.Http; +using System.Threading; +using Duplicati.Library.AutoUpdater; using Duplicati.Library.Interface; using Duplicati.Library.RestAPI; +using Duplicati.Library.Utility; using Duplicati.Server; +using Uri = System.Uri; namespace Duplicati.GUI.TrayIcon { @@ -61,14 +66,12 @@ namespace Duplicati.GUI.TrayIcon private const string DETACHED_PROCESS = "detached-process"; private const string BROWSER_COMMAND_OPTION = "browser-command"; - private const string DEFAULT_HOSTURL = "http://localhost:8200"; + private static string DEFAULT_HOSTURL => $"http://{Library.Utility.Utility.IpVersionCompatibleLoopback}:8200"; private static string _browser_command = null; private static bool disableTrayIconLogin = false; private static bool openui = false; - private static Uri serverURL = new Uri(DEFAULT_HOSTURL); - - + private static Uri serverURL = new(DEFAULT_HOSTURL); public static string BrowserCommand { get { return _browser_command; } } public static Server.Database.Connection databaseConnection = null; @@ -85,15 +88,7 @@ namespace Duplicati.GUI.TrayIcon if (OperatingSystem.IsWindows() && !Library.Utility.Utility.ParseBoolOption(options, DETACHED_PROCESS)) Library.Utility.Win32.AttachConsole(Library.Utility.Win32.ATTACH_PARENT_PROCESS); - foreach (string s in args) - if ( - s.Equals("help", StringComparison.OrdinalIgnoreCase) || - s.Equals("/help", StringComparison.OrdinalIgnoreCase) || - s.Equals("usage", StringComparison.OrdinalIgnoreCase) || - s.Equals("/usage", StringComparison.OrdinalIgnoreCase)) - options["help"] = ""; - - if (options.ContainsKey("help")) + if (HelpOptionExtensions.IsArgumentAnyHelpString(args)) { Console.WriteLine("Supported commandline arguments:"); Console.WriteLine(); @@ -154,7 +149,7 @@ namespace Duplicati.GUI.TrayIcon } else if (Library.Utility.Utility.ParseBoolOption(options, READCONFIGFROMDB_OPTION)) { - if (File.Exists(Path.Combine(Server.Program.GetDataFolderPath(options), Server.Program.SERVER_DATABASE_FILENAME))) + if (File.Exists(Path.Combine(DataFolderManager.DATAFOLDER, DataFolderManager.SERVER_DATABASE_FILENAME))) { passwordSource = PasswordSource.Database; databaseConnection = Server.Program.GetDatabaseConnection(options, true); @@ -211,31 +206,43 @@ No password provided, unable to connect to server, exiting"); do { + if (reSpawn > 0) + Thread.Sleep(1000); + try { ServicePointManager.SecurityProtocol = SecurityProtocolType.SystemDefault; using (Connection = new HttpServerConnection(serverURL, password, passwordSource, disableTrayIconLogin, acceptedHostCertificate, options)) { + // Make sure we have the latest status + // This will throw an exception if the server is not running, + // or the password is incorrect + Connection.UpdateStatus().Await(); + using (var tk = RunTrayIcon()) { if (hosted != null && Server.Program.ApplicationInstance != null) Server.Program.ApplicationInstance.SecondInstanceDetected += new Server.SingleInstance.SecondInstanceDelegate( - x => { tk.ShowUrlInWindow(serverURL.ToString()); }); + x => tk.ShowStatusWindow()); // TODO: If we change to hosted browser this should be a callback if (openui) { - try + Connection.GetStatusWindowURLAsync().ContinueWith(t => { - tk.ShowUrlInWindow(Connection.StatusWindowURL); + if (t.IsFaulted) + { + Console.WriteLine("Failed to get status window URL: " + t.Exception.Message); + tk.NotifyUser("Failed to get status window URL", t.Exception.Message, NotificationType.Error); + return; + } + tk.ShowUrlInWindow(t.Result); Server.Program.IsFirstRun = false; Server.Program.ServerPortChanged = false; - } - catch - { - } + + }); } // If the server shuts down, shut down the tray-icon as well @@ -262,7 +269,7 @@ No password provided, unable to connect to server, exiting"); } } } - catch (WebException ex) + catch (HttpRequestException ex) { System.Diagnostics.Trace.WriteLine("Request error: " + ex); Console.WriteLine("Request error: " + ex); diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs index 4906daf85..d6815b21f 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -43,7 +43,8 @@ namespace Duplicati.GUI.TrayIcon IdleWarning, IdleError, PausedError, - RunningError + RunningError, + Disconnected } public enum WindowIcons @@ -64,11 +65,16 @@ namespace Duplicati.GUI.TrayIcon void SetDefault(bool isDefault); void SetIcon(MenuIcons icon); void SetText(string text); + void SetEnabled(bool enabled); + void SetHidden(bool hidden); } public abstract class TrayIconBase : IDisposable { + protected IMenuItem m_reconnectMenu; + protected IMenuItem m_openMenu; protected IMenuItem m_pauseMenu; + protected IMenuItem m_quitMenu; protected bool m_stateIsPaused; protected Action m_onSingleClick; protected Action m_onDoubleClick; @@ -105,7 +111,7 @@ namespace Duplicati.GUI.TrayIcon protected abstract void SetMenu(IEnumerable items); - protected abstract void NotifyUser(string title, string message, NotificationType type); + public abstract void NotifyUser(string title, string message, NotificationType type); protected virtual void RegisterStatusUpdateCallback() { @@ -149,23 +155,49 @@ namespace Duplicati.GUI.TrayIcon protected IEnumerable BuildMenu() { - var tmp = CreateMenuItem("Open", MenuIcons.Status, OnStatusClicked, null); - tmp.SetDefault(true); + m_reconnectMenu = CreateMenuItem("Reconnect", MenuIcons.Resume, Reconnect, null); + m_reconnectMenu.SetHidden(true); + m_openMenu = CreateMenuItem("Open", MenuIcons.Status, OnStatusClicked, null); + m_openMenu.SetDefault(true); return new IMenuItem[] { - tmp, + m_reconnectMenu, + m_openMenu, m_pauseMenu = CreateMenuItem("Pause", MenuIcons.Pause, OnPauseClicked, null ), - CreateMenuItem("Quit", MenuIcons.Quit, OnQuitClicked, null), + m_quitMenu = CreateMenuItem("Quit", MenuIcons.Quit, OnQuitClicked, null), }; } - protected void ShowStatusWindow() + private void Reconnect() { - var window = ShowUrlInWindow(Program.Connection.StatusWindowURL); - if (window != null) + Program.Connection.UpdateStatus().ContinueWith(t => { - window.SetIcon(WindowIcons.Regular); - window.SetTitle("Duplicati status"); - } + if (t.IsFaulted) + { + Console.WriteLine("Failed to reconnect: " + t.Exception.Message); + NotifyUser("Failed to reconnect", t.Exception.Message, NotificationType.Error); + } + + }); + } + + public void ShowStatusWindow() + { + Program.Connection.GetStatusWindowURLAsync().ContinueWith(t => + { + if (t.IsFaulted) + { + Console.WriteLine("Failed to get status window URL: " + t.Exception.Message); + NotifyUser("Failed to get status window URL", t.Exception.Message, NotificationType.Error); + return; + } + + var window = ShowUrlInWindow(t.Result); + if (window != null) + { + window.SetIcon(WindowIcons.Regular); + window.SetTitle("Duplicati status"); + } + }); } protected void OnStatusClicked() @@ -207,6 +239,9 @@ namespace Duplicati.GUI.TrayIcon case SuggestedStatusIcon.Paused: this.SetIcon(TrayIcons.Paused); break; + case SuggestedStatusIcon.Disconnected: + this.SetIcon(TrayIcons.Disconnected); + break; case SuggestedStatusIcon.Ready: default: this.SetIcon(TrayIcons.Idle); @@ -226,6 +261,10 @@ namespace Duplicati.GUI.TrayIcon m_pauseMenu.SetText("Resume"); m_stateIsPaused = true; } + + m_openMenu.SetHidden(status.SuggestedStatusIcon == SuggestedStatusIcon.Disconnected); + m_pauseMenu.SetHidden(status.SuggestedStatusIcon == SuggestedStatusIcon.Disconnected); + m_reconnectMenu.SetHidden(status.SuggestedStatusIcon != SuggestedStatusIcon.Disconnected); }); } diff --git a/Duplicati/Library/AutoUpdater/AutoUpdateSettings.cs b/Duplicati/Library/AutoUpdater/AutoUpdateSettings.cs index 020d598ce..b6b66e191 100644 --- a/Duplicati/Library/AutoUpdater/AutoUpdateSettings.cs +++ b/Duplicati/Library/AutoUpdater/AutoUpdateSettings.cs @@ -1,24 +1,26 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +#nullable enable + using System; using System.Collections.Generic; using System.Linq; @@ -27,28 +29,78 @@ using System.Text.RegularExpressions; namespace Duplicati.Library.AutoUpdater { + /// + /// Contains settings for the auto-updater + /// public static class AutoUpdateSettings { - private static readonly Dictionary _cache = new Dictionary(); + /// + /// The appname file resource name + /// private const string APP_NAME = "AutoUpdateAppName.txt"; + /// + /// The update URL file resource name + /// private const string UPDATE_URL = "AutoUpdateURL.txt"; + /// + /// The update key file resource name + /// private const string UPDATE_KEY = "AutoUpdateSignKeys.txt"; + /// + /// The update channel file resource name + /// private const string UPDATE_CHANNEL = "AutoUpdateBuildChannel.txt"; + /// + /// The update install file template resource name + /// private const string UPDATE_INSTALL_FILE = "AutoUpdateInstallIDTemplate.txt"; + /// + /// The update machine file template resource name + /// private const string UPDATE_MACHINE_FILE = "AutoUpdateMachineIDTemplate.txt"; + /// + /// The OEM file name + /// private const string OEM_APP_NAME = "oem-app-name.txt"; + /// + /// The OEM update URL file name + /// private const string OEM_UPDATE_URL = "oem-update-url.txt"; + /// + /// The OEM update key file name + /// private const string OEM_UPDATE_KEY = "oem-update-key.txt"; + /// + /// The OEM update install template file name + /// private const string OEM_UPDATE_INSTALL_FILE = "oem-update-installid.txt"; + /// + /// The update URL environment variable name template + /// public const string UPDATEURL_ENVNAME_TEMPLATE = "AUTOUPDATER_{0}_URLS"; + /// + /// The update channel environment variable name template + /// public const string UPDATECHANNEL_ENVNAME_TEMPLATE = "AUTOUPDATER_{0}_CHANNEL"; + /// + /// The package prefix group name in the regex + /// internal const string MATCH_UPDATE_URL_PREFIX_GROUP = "prefix"; + /// + /// The channel group name in the regex + /// internal const string MATCH_UPDATE_URL_CHANNEL_GROUP = "channel"; + /// + /// The filename group name in the regex + /// internal const string MATCH_UPDATE_URL_FILENAME_GROUP = "filename"; + /// + /// The regex to match the auto-update URL and replace the channel + /// internal static readonly Regex MATCH_AUTOUPDATE_URL = new Regex(string.Format( "(?<{0}>.+)(?<{1}>{3})(?<{2}>/([^/]+).manifest)", @@ -57,27 +109,21 @@ namespace Duplicati.Library.AutoUpdater MATCH_UPDATE_URL_FILENAME_GROUP, string.Join("|", Enum.GetNames(typeof(ReleaseType)).Union(new[] { "preview" }))), RegexOptions.Compiled | RegexOptions.IgnoreCase); - - static AutoUpdateSettings() + /// + /// Reads a resource text from the assembly, with an optional OEM filename override + /// + /// The embedded resource name + /// The OEM filename override + /// The text of the resource + private static string ReadResourceText(string name, string? oemname) { - ReadResourceText(APP_NAME, OEM_APP_NAME); - ReadResourceText(UPDATE_URL, OEM_UPDATE_URL); - ReadResourceText(UPDATE_KEY, OEM_UPDATE_KEY); - ReadResourceText(UPDATE_INSTALL_FILE, OEM_UPDATE_INSTALL_FILE); - ReadResourceText(UPDATE_MACHINE_FILE, null); - ReadResourceText(UPDATE_CHANNEL, null); - } - - private static string ReadResourceText(string name, string oemname) - { - // First try to read from _cache - if (_cache.TryGetValue(name, out string result)) - return result; - + string? result = null; try { - using (var rd = new System.IO.StreamReader(System.Reflection.Assembly.GetExecutingAssembly().GetManifestResourceStream(typeof(AutoUpdateSettings), name))) - result = rd.ReadToEnd(); + using (var rs = System.Reflection.Assembly.GetExecutingAssembly().GetManifestResourceStream(typeof(AutoUpdateSettings), name)) + if (rs != null) + using (var rd = new System.IO.StreamReader(rs)) + result = rd.ReadToEnd(); } catch (Exception ex) { @@ -99,121 +145,154 @@ namespace Duplicati.Library.AutoUpdater else result = result.Trim(); - _cache[name] = result; return result; } - public static string[] URLs - { - get - { - if (UsesAlternateURLs) - return Environment.GetEnvironmentVariable(string.Format(UPDATEURL_ENVNAME_TEMPLATE, AppName)).Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries); - else - return ReadResourceText(UPDATE_URL, OEM_UPDATE_URL).Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries); - } - } + /// + /// The URLs to check for updates + /// + public static string[] URLs => _URLs.Value; - public static bool UsesAlternateURLs + /// + /// The alternate URLs to check for updates, lazy evaluated + /// + private static readonly Lazy _alternateURLs = new Lazy(() => { - get - { - return !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(string.Format(UPDATEURL_ENVNAME_TEMPLATE, AppName))); - } - } + return Environment.GetEnvironmentVariable(string.Format(UPDATEURL_ENVNAME_TEMPLATE, AppName)); + }); - public static ReleaseType DefaultUpdateChannel + /// + /// The URLs to check for updates, lazy evaluated + /// + private static readonly Lazy _URLs = new Lazy(() => { - get - { - var channelstring = Environment.GetEnvironmentVariable(string.Format(UPDATECHANNEL_ENVNAME_TEMPLATE, AppName)); + if (!string.IsNullOrWhiteSpace(_alternateURLs.Value)) + return _alternateURLs.Value.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries); + else + return ReadResourceText(UPDATE_URL, OEM_UPDATE_URL).Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries); + }); - if (UsesAlternateURLs && string.IsNullOrWhiteSpace(channelstring)) + /// + /// Indicates if alternate URLs are in use + /// + public static bool UsesAlternateURLs => !string.IsNullOrWhiteSpace(_alternateURLs.Value); + + /// + /// The default update channel + /// + public static ReleaseType DefaultUpdateChannel => _defaultUpdateChannel.Value; + + /// + /// The default update channel, lazy evaluated + /// + private static readonly Lazy _defaultUpdateChannel = new Lazy(() => + { + var channelstring = Environment.GetEnvironmentVariable(string.Format(UPDATECHANNEL_ENVNAME_TEMPLATE, AppName)); + + if (UsesAlternateURLs && string.IsNullOrWhiteSpace(channelstring)) + { + foreach (var url in URLs) { - foreach (var url in URLs) + var match = AutoUpdateSettings.MATCH_AUTOUPDATE_URL.Match(url); + if (match.Success) { - var match = AutoUpdateSettings.MATCH_AUTOUPDATE_URL.Match(url); - if (match.Success) - { - channelstring = match.Groups[AutoUpdateSettings.MATCH_UPDATE_URL_CHANNEL_GROUP].Value; - break; - } + channelstring = match.Groups[AutoUpdateSettings.MATCH_UPDATE_URL_CHANNEL_GROUP].Value; + break; } } - - if (string.IsNullOrWhiteSpace(channelstring)) - channelstring = BuildUpdateChannel; - - // Update from older builds - if (string.Equals(channelstring, "preview", StringComparison.OrdinalIgnoreCase)) - channelstring = ReleaseType.Experimental.ToString(); - if (string.Equals(channelstring, "rene", StringComparison.OrdinalIgnoreCase)) - channelstring = ReleaseType.Canary.ToString(); - - ReleaseType rt; - if (!Enum.TryParse(channelstring, true, out rt)) - rt = ReleaseType.Stable; - - return rt; } - } - public static string AppName - { - get { return ReadResourceText(APP_NAME, OEM_APP_NAME); } - } + if (string.IsNullOrWhiteSpace(channelstring)) + channelstring = BuildUpdateChannel; - public static string BuildUpdateChannel - { - get { return ReadResourceText(UPDATE_CHANNEL, null); } - } + // Update from older builds + if (string.Equals(channelstring, "preview", StringComparison.OrdinalIgnoreCase)) + channelstring = ReleaseType.Experimental.ToString(); + if (string.Equals(channelstring, "rene", StringComparison.OrdinalIgnoreCase)) + channelstring = ReleaseType.Canary.ToString(); - public static string UpdateInstallFileText - { - get { return string.Format(ReadResourceText(UPDATE_INSTALL_FILE, OEM_UPDATE_INSTALL_FILE), Guid.NewGuid().ToString("N")); } - } + ReleaseType rt; + if (!Enum.TryParse(channelstring, true, out rt)) + rt = ReleaseType.Stable; + return rt; + }); + + /// + /// The application name + /// + public static string AppName => _appName.Value; + + /// + /// The application name, lazy evaluated + /// + private static readonly Lazy _appName = new Lazy(() => ReadResourceText(APP_NAME, OEM_APP_NAME)); + + /// + /// The build update channel + /// + public static string BuildUpdateChannel => _buildUpdateChannel.Value; + + /// + /// The build update channel, lazy evaluated + /// + private static readonly Lazy _buildUpdateChannel = new Lazy(() => ReadResourceText(UPDATE_CHANNEL, null)); + + /// + /// The text for the update install file + /// + public static string UpdateInstallFileText => string.Format(ReadResourceText(UPDATE_INSTALL_FILE, OEM_UPDATE_INSTALL_FILE), Guid.NewGuid().ToString("N")); + + /// + /// Updates the machine file text with the machine ID + /// + /// The machine ID to use + /// The updated text public static string UpdateMachineFileText(string machineid) => string.Format(ReadResourceText(UPDATE_MACHINE_FILE, "{0}"), string.IsNullOrWhiteSpace(machineid) ? Guid.NewGuid().ToString("N") : machineid); - public static RSACryptoServiceProvider[] SignKeys + /// + /// The keys to use for signing + /// + public static RSACryptoServiceProvider[] SignKeys => _signKeys.Value; + + /// + /// The keys to use for signing, lazy evaluated + /// + private static readonly Lazy _signKeys = new Lazy(() => { - get + var keys = new List(); + + try { - var keys = new List(); + var src = ReadResourceText(UPDATE_KEY, OEM_UPDATE_KEY); - try + // Allow multiple keys, one per line + // For fallback, read the whole string as a key, in case there are old ones with line breaks + var keystrings = src.Split(new char[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .Prepend(src.Trim()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(); + + foreach (var str in keystrings) { - var src = ReadResourceText(UPDATE_KEY, OEM_UPDATE_KEY); - - // Allow multiple keys, one per line - // For fallback, read the whole string as a key, in case there are old ones with line breaks - - var keystrings = src.Split(new char[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries) - .Select(x => x.Trim()) - .Prepend(src.Trim()) - .Where(x => !string.IsNullOrWhiteSpace(x)) - .Distinct(); - - foreach (var str in keystrings) + try { - try - { - var key = new RSACryptoServiceProvider(); - key.FromXmlString(str); - keys.Add(key); - } - catch - { } + var key = new RSACryptoServiceProvider(); + key.FromXmlString(str); + keys.Add(key); } + catch + { } } - catch - { - } - - return keys.ToArray(); } - } + catch + { + } + + return keys.ToArray(); + }); } } diff --git a/Duplicati/Library/AutoUpdater/DataFolderLocator.cs b/Duplicati/Library/AutoUpdater/DataFolderLocator.cs new file mode 100644 index 000000000..1aafa1d16 --- /dev/null +++ b/Duplicati/Library/AutoUpdater/DataFolderLocator.cs @@ -0,0 +1,148 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Linq; +using System.Collections.Generic; +using Duplicati.Library.Common.IO; + +#nullable enable + +namespace Duplicati.Library.AutoUpdater; + +/// +/// Helper class to locate the default storage folder for the application +/// +public static class DataFolderLocator +{ + /// + /// Finds a default storage folder, using the operating system specific locations. + /// The targetfilename is used to detect locations that are used in previous versions. + /// If the targetfilename is found in an old location, but not the current, the old location is used. + /// Note that the folder is not created, only the path is returned. + /// If the data folder is overriden, the overriden folder is used, and no search is performed. + /// + /// The filename to look for + /// Whether to create the folder if it does not exist + /// The default storage folder + public static string GetDefaultStorageFolder(string targetfilename, bool autoCreate) + { + var folder = DataFolderManager.OVERRIDEN_DATAFOLDER + ? DataFolderManager.DATAFOLDER + : GetDefaultStorageFolderInternal(targetfilename, AutoUpdateSettings.AppName); + + if (SystemIO.IO_OS.DirectoryExists(folder)) + { + if (!SystemIO.IO_OS.FileExists(System.IO.Path.Combine(folder, Util.InsecurePermissionsMarkerFile))) + SystemIO.IO_OS.DirectorySetPermissionUserRWOnly(folder); + } + else if (autoCreate) + { + // Create the folder + SystemIO.IO_OS.DirectoryCreate(folder); + + // Make sure the folder is only accessible by the current user + SystemIO.IO_OS.DirectorySetPermissionUserRWOnly(folder); + } + + return folder; + } + + /// + /// Finds a default storage folder, using the operating system specific locations. + /// The targetfilename is used to detect locations that are used in previous versions. + /// If the targetfilename is found in an old location, but not the current, the old location is used. + /// Note that the folder is not created, only the path is returned. + /// + /// The filename to look for + /// The name of the application + /// The default storage folder + internal static string GetDefaultStorageFolderInternal(string targetfilename, string appName) + { + //Normal mode uses the systems "(Local) Application Data" folder + // %LOCALAPPDATA% on Windows, ~/.config on Linux + var folder = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), appName); + + if (OperatingSystem.IsWindows()) + { + // Special handling for Windows: + // - Older versions use %APPDATA% + // - New versions use %LOCALAPPDATA% + // - And prevent using C:\Windows\ + var newlocation = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), appName); + + var folderOrder = new List() { + folder, + newlocation + }; + + // If %LOCALAPPDATA% is inside the Windows folder, prefer a LocalService folder instead + if (Common.IO.Util.IsPathUnderWindowsFolder(newlocation)) + folderOrder.Add(System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), appName)); + + // Prefer the most recent location + var matches = folderOrder.AsEnumerable() + .Reverse() + .Where(x => System.IO.File.Exists(System.IO.Path.Combine(x, targetfilename))) + .ToList(); + + // Use the most recent location found with content + // If none are found, use the most recent location + folder = matches.FirstOrDefault() ?? folderOrder.Last(); + } + + if (OperatingSystem.IsMacOS()) + { + // Special handling for MacOS: + // - Older versions use ~/.config/ + // - but new versions use ~/Library/Application\ Support/ + var configfolder = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", appName); + + var prevfile = System.IO.Path.Combine(configfolder, targetfilename); + var curfile = System.IO.Path.Combine(folder, targetfilename); + + // If the old file exists, and not the new file, we use the old + // Otherwise we use the new location + if (!System.IO.File.Exists(curfile) && System.IO.File.Exists(prevfile)) + folder = configfolder; + } + + if (OperatingSystem.IsLinux() && (folder == $"/{appName}" || folder == appName)) + { + // Special handling for Linux with no home folder: + // - Older versions use / + // - but new versions use /var/lib/ + var libfolder = System.IO.Path.Combine("var", "lib", appName); + + var curfile = System.IO.Path.Combine(libfolder, targetfilename); + var prevfile = System.IO.Path.Combine(folder, targetfilename); + + // If the old file exists, and not the new file, we use the old + // Otherwise we use the new location + if (System.IO.File.Exists(curfile) || !System.IO.File.Exists(prevfile)) + folder = libfolder; + } + + return folder; + } +} + + diff --git a/Duplicati/Library/AutoUpdater/DataFolderManager.cs b/Duplicati/Library/AutoUpdater/DataFolderManager.cs new file mode 100644 index 000000000..36c1f79e8 --- /dev/null +++ b/Duplicati/Library/AutoUpdater/DataFolderManager.cs @@ -0,0 +1,253 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +#nullable enable + +using System; +using System.IO; +using System.Linq; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Utility; + +namespace Duplicati.Library.AutoUpdater; + +/// +/// Manages the data folder for the application +/// +public static class DataFolderManager +{ + /// + /// The folder where the machine id is placed + /// + public static readonly string DATAFOLDER; + + /// + /// The installation ID filename stored in + /// + private const string INSTALL_FILE = "installation.txt"; + + /// + /// The machine ID filename stored in + /// + private const string MACHINE_FILE = "machineid.txt"; + + /// + /// The option name for portable mode + /// + public const string PORTABLE_MODE_OPTION = "portable-mode"; + /// + /// The option anme for the server data folder + /// + public const string SERVER_DATAFOLDER_OPTION = "server-datafolder"; + + /// + /// The app name to use for variables + /// + private static readonly string APPNAME = AutoUpdateSettings.AppName; + + /// + /// The name of the environment variable that allows overriding the path to the data folder used by Duplicati + /// + public static readonly string DATAFOLDER_ENV_NAME = $"{APPNAME}_HOME".ToUpperInvariant(); + + /// + /// Name of the database file + /// + public static readonly string SERVER_DATABASE_FILENAME = $"{APPNAME}-server.sqlite"; + + /// + /// Flag to indicate if the application is running in portable mode + /// + public static readonly bool PORTABLE_MODE; + + /// + /// Flag to indicate if the data folder was overriden + /// + public static readonly bool OVERRIDEN_DATAFOLDER; + + /// + /// Replication of the argument parsing from the main Duplicati codebase + /// + /// The option to extract + /// null if the option is not found, otherwise the value of the option + private static string? ExtractOptionSlim(string option) + { + var opt = $"--{option}"; + var args = Environment.GetCommandLineArgs().Skip(1).ToArray(); + var match = args.Select((token, index) => new { token, index }) + .LastOrDefault(x => + string.Equals(x.token, opt, StringComparison.OrdinalIgnoreCase) + || + x.token.StartsWith(opt + "=", StringComparison.OrdinalIgnoreCase) + ); + + // Not found, try the environment variable + if (string.IsNullOrWhiteSpace(match?.token)) + return Environment.GetEnvironmentVariable($"{AutoUpdateSettings.AppName}__{option.Replace('-', '_')}".ToUpperInvariant()); + + // Found in the form --option=value + if (match.token.StartsWith(opt + "=", StringComparison.OrdinalIgnoreCase)) + return match.token.Substring(opt.Length + 1).Trim('"'); + + // Found in the form --option value + if (match.index + 1 < args.Length) + { + var value = args[match.index + 1]; + if (!value.StartsWith("--")) + return value; + } + + // Found, but no value, just the option + return ""; + } + + /// + /// Replication of the boolean parsing from the main Duplicati codebase + /// + /// The value to parse + /// true if the value is a truthy value, otherwise false + private static bool ParseBoolSlim(string? value) + { + // In debug builds, we default to portable mode + if (value == null) +#if DEBUG + return true; +#else + return false; +#endif + + if ( + value.Equals("false", StringComparison.OrdinalIgnoreCase) + || value.Equals("0", StringComparison.OrdinalIgnoreCase) + || value.Equals("no", StringComparison.OrdinalIgnoreCase) + || value.Equals("off", StringComparison.OrdinalIgnoreCase) + ) + return false; + + return true; + } + + static DataFolderManager() + { + // Trigger portable mode, if the flag is set + PORTABLE_MODE = ParseBoolSlim(ExtractOptionSlim(PORTABLE_MODE_OPTION)); + + // The environment variable is a legacy setting + var envOverride = Environment.GetEnvironmentVariable(DATAFOLDER_ENV_NAME); + + // These are mainly supported by the Server + var datafolderArg = ExtractOptionSlim(SERVER_DATAFOLDER_OPTION); + + // Prefer the command line argument over the environment variable + if (!string.IsNullOrWhiteSpace(datafolderArg)) + { + OVERRIDEN_DATAFOLDER = true; + DATAFOLDER = Util.AppendDirSeparator(Environment.ExpandEnvironmentVariables(datafolderArg).Trim('"')); + } + // Portable mode is prefered over the environment variable + else if (PORTABLE_MODE) + { + OVERRIDEN_DATAFOLDER = true; + DATAFOLDER = Util.AppendDirSeparator(Path.Combine(UpdaterManager.INSTALLATIONDIR, "data")); + } + // Use the legacy environment variable, if set + else if (!string.IsNullOrWhiteSpace(envOverride)) + { + OVERRIDEN_DATAFOLDER = true; + DATAFOLDER = Util.AppendDirSeparator(Environment.ExpandEnvironmentVariables(envOverride).Trim('"')); + } + // Use the default location + else + { + OVERRIDEN_DATAFOLDER = false; + DATAFOLDER = Util.AppendDirSeparator(DataFolderLocator.GetDefaultStorageFolderInternal(SERVER_DATABASE_FILENAME, APPNAME)); + } + + if (Directory.Exists(DATAFOLDER)) + { + if (!File.Exists(Path.Combine(DATAFOLDER, Util.InsecurePermissionsMarkerFile))) + SystemIO.IO_OS.DirectorySetPermissionUserRWOnly(DATAFOLDER); + } + else + { + Directory.CreateDirectory(DATAFOLDER); + SystemIO.IO_OS.DirectorySetPermissionUserRWOnly(DATAFOLDER); + } + + if (!File.Exists(Path.Combine(DATAFOLDER, INSTALL_FILE))) + { + // In case there was already a machine id file from 2.0.8.1 or older, copy it to the new location + if (File.Exists(Path.Combine(DATAFOLDER, "updates", INSTALL_FILE))) + File.Copy(Path.Combine(DATAFOLDER, "updates", INSTALL_FILE), Path.Combine(DATAFOLDER, INSTALL_FILE), true); + else + File.WriteAllText(Path.Combine(DATAFOLDER, INSTALL_FILE), AutoUpdateSettings.UpdateInstallFileText); + } + + if (!File.Exists(Path.Combine(DATAFOLDER, MACHINE_FILE))) + File.WriteAllText(Path.Combine(DATAFOLDER, MACHINE_FILE), AutoUpdateSettings.UpdateMachineFileText(InstallID)); + } + + /// + /// The unique machine installation ID + /// + public static string InstallID => _installID.Value; + + /// + /// The unique machine ID, lazy evaluated + /// + private static readonly Lazy _installID = new(() => + { + try { return File.ReadAllLines(Path.Combine(DATAFOLDER!, INSTALL_FILE)).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x))?.Trim() ?? ""; } + catch { } + + return ""; + }); + + /// + /// The unique machine ID + /// + public static string MachineID => _machineID.Value; + + /// + /// The unique machine ID, lazy evaluated + /// + private static readonly Lazy _machineID = new(() => + { + string? machinedId = null; + try { machinedId = File.ReadAllLines(Path.Combine(DATAFOLDER!, MACHINE_FILE)).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x))?.Trim() ?? ""; } + catch { } + + return string.IsNullOrWhiteSpace(machinedId) + ? InstallID + : machinedId; + }); + + /// + /// The machine name, lazy evaluated + /// + private static readonly Lazy _machineName = new(MachineNameReader.GetMachineName); + + /// + /// The machine name + /// + public static readonly string MachineName = _machineName.Value; + +} \ No newline at end of file diff --git a/Duplicati/Library/AutoUpdater/PackageHelper.cs b/Duplicati/Library/AutoUpdater/PackageHelper.cs index 5f1ee0341..4ceefc41e 100644 --- a/Duplicati/Library/AutoUpdater/PackageHelper.cs +++ b/Duplicati/Library/AutoUpdater/PackageHelper.cs @@ -89,6 +89,14 @@ namespace Duplicati.Library.AutoUpdater /// The secret tool /// SecretTool, + /// + /// The sync tool + /// + SyncTool, + /// + /// The source tool + /// + SourceTool } @@ -115,6 +123,8 @@ namespace Duplicati.Library.AutoUpdater NamedExecutable.Service => OperatingSystem.IsWindows() ? "Duplicati.Service.exe" : "duplicati-service", NamedExecutable.Agent => OperatingSystem.IsWindows() ? "Duplicati.Agent.exe" : "duplicati-agent", NamedExecutable.SecretTool => OperatingSystem.IsWindows() ? "Duplicati.CommandLine.SecretTool.exe" : "duplicati-secret-tool", + NamedExecutable.SyncTool => OperatingSystem.IsWindows() ? "Duplicati.CommandLine.SyncTool.exe" : "duplicati-sync-tool", + NamedExecutable.SourceTool => OperatingSystem.IsWindows() ? "Duplicati.CommandLine.Sourcetool.exe" : "duplicati-source-tool", _ => throw new ArgumentException($"Named executable not known: {exe}", nameof(exe)) }; diff --git a/Duplicati/Library/AutoUpdater/PreloadSettingsLoader.cs b/Duplicati/Library/AutoUpdater/PreloadSettingsLoader.cs index 0c311b89e..f1e34cbfc 100644 --- a/Duplicati/Library/AutoUpdater/PreloadSettingsLoader.cs +++ b/Duplicati/Library/AutoUpdater/PreloadSettingsLoader.cs @@ -74,7 +74,7 @@ public static class PreloadSettingsLoader // The path for preload settings with the install directory Path.Combine( Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location) ?? "", - "preload.json" + FILE_NAME ), // The path for preload settings specified with an environment variable @@ -109,11 +109,7 @@ public static class PreloadSettingsLoader // User-context path for preload settings, // the same default path as where other data is stored - Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - FOLDER_NAME, - FILE_NAME - ) + Path.Combine(DataFolderManager.DATAFOLDER, FILE_NAME), } .Concat(PortablePreloadPaths) .Where(x => !string.IsNullOrEmpty(x)) @@ -136,7 +132,7 @@ public static class PreloadSettingsLoader /// The database settings public static void ConfigurePreloadSettings(ref string[] arguments, PackageHelper.NamedExecutable executable, out Dictionary dbsettings) { - var (env, args, db) = GetExecutableMergedSettings(executable, IsPortableModeRequested(arguments)); + var (env, args, db) = GetExecutableMergedSettings(executable, DataFolderManager.PORTABLE_MODE); dbsettings = db; ApplyEnvironmentVariables(env); @@ -151,46 +147,6 @@ public static class PreloadSettingsLoader private static string GetArgumentName(string arg) => arg.Split('=', 2)[0]; - /// - /// Helper function to check if portable mode is requested. - /// This is a rudimentary function that mimics the commandline parser, - /// which is not loaded during preload - /// - /// The arguments to check - /// true if portable mode is requested, false otherwise - private static bool IsPortableModeRequested(string[] args) - { - var candiate = args.Select((x, i) => (x, i)) - .Where(x => x.x != null) - .Where(x => x.x.Equals("--portable-mode", StringComparison.OrdinalIgnoreCase) || x.x.StartsWith("--portable-mode=", StringComparison.OrdinalIgnoreCase)) - .LastOrDefault(); - if (candiate.x == null) - return false; - - var value = (string?)null; - if (candiate.x.Contains("=")) - value = candiate.x.Split('=', 2)[1]; - else - { - value = args.ElementAtOrDefault(candiate.i + 1); - if (value == null || value.StartsWith("--")) - value = "true"; - } - - if (value == null) - return false; - - value = value.TrimStart('"').TrimEnd('"').ToLowerInvariant(); - return value switch - { - "1" => true, - "on" => true, - "true" => true, - "yes" => true, - _ => false, - }; - - } /// /// Gets the merged settings for the given executable diff --git a/Duplicati/Library/AutoUpdater/Program.cs b/Duplicati/Library/AutoUpdater/Program.cs index 064485578..ebfb6d949 100644 --- a/Duplicati/Library/AutoUpdater/Program.cs +++ b/Duplicati/Library/AutoUpdater/Program.cs @@ -53,6 +53,7 @@ namespace Duplicati.Library.AutoUpdater return 0; case "check": { + Console.WriteLine($"Checking for updates on channel {AutoUpdateSettings.DefaultUpdateChannel} ..."); var update = UpdaterManager.CheckForUpdate(); if (update == null) Console.WriteLine("No updates found"); @@ -116,7 +117,7 @@ namespace Duplicati.Library.AutoUpdater Console.WriteLine("{0} - Choose different channel than the default {1}, valid settings: {2}", string.Format(AutoUpdateSettings.UPDATECHANNEL_ENVNAME_TEMPLATE, AutoUpdateSettings.AppName), AutoUpdater.AutoUpdateSettings.DefaultUpdateChannel, string.Join(",", Enum.GetNames(typeof(ReleaseType)).Where(x => x != ReleaseType.Unknown.ToString()))); Console.WriteLine(); Console.WriteLine("Updates are downloaded from: {0}", string.Join(";", AutoUpdateSettings.URLs)); - Console.WriteLine("Machine settings are installed in: {0}", UpdaterManager.UPDATEDIR); + Console.WriteLine("Settings and configuration files are placed in: {0}", DataFolderManager.DATAFOLDER); Console.WriteLine("This version is \"{0}\" ({1}) and is installed in: {2}", UpdaterManager.SelfVersion.Displayname, UpdaterManager.SelfVersion.Version, UpdaterManager.INSTALLATIONDIR); Console.WriteLine(); } diff --git a/Duplicati/Library/AutoUpdater/UpdaterManager.cs b/Duplicati/Library/AutoUpdater/UpdaterManager.cs index f5d254f81..ce88ae5ad 100644 --- a/Duplicati/Library/AutoUpdater/UpdaterManager.cs +++ b/Duplicati/Library/AutoUpdater/UpdaterManager.cs @@ -1,4 +1,4 @@ -// Copyright (C) 2025, The Duplicati Team +// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a @@ -19,6 +19,8 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +#nullable enable + using System; using System.Linq; using System.Collections.Generic; @@ -28,32 +30,32 @@ using System.Diagnostics; using System.Text.Json; using System.Net.Http; using System.Threading; +using Duplicati.Library.Common.IO; namespace Duplicati.Library.AutoUpdater { + /// + /// Handles operations related to updating the application + /// public static class UpdaterManager { /// /// The RSA key used to sign the manifest /// - private static readonly System.Security.Cryptography.RSA[] SIGN_KEYS = AutoUpdateSettings.SignKeys; + private static System.Security.Cryptography.RSA[] SIGN_KEYS => AutoUpdateSettings.SignKeys; /// /// Urls to check for updated packages /// - private static readonly string[] MANIFEST_URLS = AutoUpdateSettings.URLs; + private static string[] MANIFEST_URLS => AutoUpdateSettings.URLs; /// /// The app name to show /// - private static readonly string APPNAME = AutoUpdateSettings.AppName; + private static string APPNAME => AutoUpdateSettings.AppName; /// /// The version that the updater supports /// public const int SUPPORTED_PACKAGE_UPDATER_VERSION = 2; /// - /// The folder where the machine id is placed - /// - public static readonly string UPDATEDIR; - /// /// The directory where the program is running from /// public static readonly string INSTALLATIONDIR; @@ -62,6 +64,10 @@ namespace Duplicati.Library.AutoUpdater /// public static readonly bool DISABLE_UPDATE_CHECK = Debugger.IsAttached || Utility.Utility.ParseBool(Environment.GetEnvironmentVariable(string.Format(SKIPUPDATE_ENVNAME_TEMPLATE, APPNAME)), false); /// + /// The operating system display name + /// + public static readonly string OperatingSystemName = OperatingSystem.IsWindows() ? "Windows" : OperatingSystem.IsLinux() ? "Linux" : OperatingSystem.IsMacOS() ? "MacOS" : "Unknown"; + /// /// The update information for the running version /// public static readonly UpdateInfo SelfVersion; @@ -69,17 +75,13 @@ namespace Duplicati.Library.AutoUpdater /// /// Event trigger for errors on update /// - public static event Action OnError; + public static event Action? OnError; /// /// Common formatting string for date-time values /// private const string DATETIME_FORMAT = "yyyymmddhhMMss"; /// - /// The template for the environment variable name that allows an overriden root folder - /// - private const string UPDATEINSTALLDIR_ENVNAME_TEMPLATE = "AUTOUPDATER_{0}_UPDATE_ROOT"; - /// /// The template for the environment variable that toggles disabling updates /// public const string SKIPUPDATE_ENVNAME_TEMPLATE = "AUTOUPDATER_{0}_SKIP_UPDATE"; @@ -91,20 +93,11 @@ namespace Duplicati.Library.AutoUpdater /// The name of the file that contains the package type id, located in the folder /// private const string PACKAGE_TYPE_FILE = "package_type_id.txt"; - /// - /// The installation ID filename stored in - /// - private const string INSTALL_FILE = "installation.txt"; - - /// - /// The machine ID filename stored in - /// - private const string MACHINE_FILE = "machineid.txt"; /// /// Gets the last version found from an update /// - public static UpdateInfo LastUpdateCheckVersion { get; private set; } + public static UpdateInfo? LastUpdateCheckVersion { get; private set; } /// /// The default timeout in seconds for download operations @@ -122,65 +115,10 @@ namespace Duplicati.Library.AutoUpdater static UpdaterManager() { // Set the installation path - INSTALLATIONDIR = Path.GetDirectoryName(Duplicati.Library.Utility.Utility.getEntryAssembly().Location); - - // Check for override - if (string.IsNullOrWhiteSpace(System.Environment.GetEnvironmentVariable(string.Format(UPDATEINSTALLDIR_ENVNAME_TEMPLATE, APPNAME)))) - { - // OS specific folders for probing - var candidates = new List(); - if (OperatingSystem.IsWindows()) - { - candidates.Add(System.IO.Path.Combine(System.Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), APPNAME)); - candidates.Add(System.IO.Path.Combine(System.Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), APPNAME)); - } - else - { - candidates.Add(System.IO.Path.Combine(System.Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), APPNAME)); - } - - if (OperatingSystem.IsMacOS()) - candidates.Add(System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), "Library", "Application Support", APPNAME)); - - // Find the first writeable directory in the list - UPDATEDIR = candidates.FirstOrDefault(p => !string.IsNullOrWhiteSpace(p) && System.IO.Directory.Exists(p) && TestDirectoryIsWriteable(p)); - - // Try to create a writeable folder, if none is found - if (string.IsNullOrWhiteSpace(UPDATEDIR)) - UPDATEDIR = candidates.Where(p => !string.IsNullOrWhiteSpace(p) && !System.IO.Directory.Exists(p)) - .Select(p => - { - try { System.IO.Directory.CreateDirectory(p); } - catch { } - return p; - }) - .Where(p => System.IO.Directory.Exists(p) && TestDirectoryIsWriteable(p)) - .FirstOrDefault(); - } - else - { - // Use override, no checks - UPDATEDIR = Environment.ExpandEnvironmentVariables(System.Environment.GetEnvironmentVariable(string.Format(UPDATEINSTALLDIR_ENVNAME_TEMPLATE, APPNAME))); - } - - if (!string.IsNullOrWhiteSpace(UPDATEDIR)) - { - if (!System.IO.File.Exists(System.IO.Path.Combine(UPDATEDIR, INSTALL_FILE))) - { - // In case there was already a machine id file, copy it to the new location - if (System.IO.File.Exists(System.IO.Path.Combine(UPDATEDIR, "updates", INSTALL_FILE))) - System.IO.File.Copy(System.IO.Path.Combine(UPDATEDIR, "updates", INSTALL_FILE), System.IO.Path.Combine(UPDATEDIR, INSTALL_FILE), true); - else - System.IO.File.WriteAllText(System.IO.Path.Combine(UPDATEDIR, INSTALL_FILE), AutoUpdateSettings.UpdateInstallFileText); - } - - if (!System.IO.File.Exists(System.IO.Path.Combine(UPDATEDIR, MACHINE_FILE))) - System.IO.File.WriteAllText(System.IO.Path.Combine(UPDATEDIR, MACHINE_FILE), AutoUpdateSettings.UpdateMachineFileText(InstallID)); - - } + INSTALLATIONDIR = Util.AppendDirSeparator(Path.GetDirectoryName(Utility.Utility.getEntryAssembly().Location)); // Attempt to read the installed manifest file - UpdateInfo selfVersion = null; + UpdateInfo? selfVersion = null; try { selfVersion = ReadInstalledManifest(INSTALLATIONDIR); @@ -190,116 +128,62 @@ namespace Duplicati.Library.AutoUpdater } // In case the installed manifest is broken, try to set some sane values - if (selfVersion == null) - { - SelfVersion = new UpdateInfo( - MinimumCompatibleVersion: 1, - PackageUpdaterVersion: 1, - IncompatibleUpdateUrl: string.Empty, - GenericUpdatePageUrl: "https://duplicati.com/download", - UpdateSeverity: null, - ChangeInfo: null, - Packages: null, - Displayname: string.IsNullOrWhiteSpace(Duplicati.License.VersionNumbers.TAG) ? "Current" : Duplicati.License.VersionNumbers.TAG, - Version: System.Reflection.Assembly.GetExecutingAssembly().GetName().Version.ToString(), - ReleaseTime: new DateTime(0), - ReleaseType: + SelfVersion = selfVersion ?? new UpdateInfo( + MinimumCompatibleVersion: 1, + PackageUpdaterVersion: 1, + IncompatibleUpdateUrl: string.Empty, + GenericUpdatePageUrl: "https://duplicati.com/download", + UpdateSeverity: null, + ChangeInfo: null, + Packages: null, + Displayname: string.IsNullOrWhiteSpace(License.VersionNumbers.TAG) ? "Current" : License.VersionNumbers.TAG, + Version: System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString(), + ReleaseTime: new DateTime(0), + ReleaseType: #if DEBUG - "Debug" + "Debug" #else string.IsNullOrWhiteSpace(AutoUpdateSettings.BuildUpdateChannel) ? "Nightly" : AutoUpdateSettings.BuildUpdateChannel #endif - ); - } + ); } - public static Version TryParseVersion(string str) + public static Version TryParseVersion(string? str) { - Version v; - if (Version.TryParse(str, out v)) + if (Version.TryParse(str, out var v)) return v; else return new Version(0, 0); } - private static bool TestDirectoryIsWriteable(string path) - { - var p2 = System.IO.Path.Combine(path, "test-" + DateTime.UtcNow.ToString(DATETIME_FORMAT, System.Globalization.CultureInfo.InvariantCulture)); - var probe = System.IO.Directory.Exists(path) ? p2 : path; - - if (!System.IO.Directory.Exists(probe)) - { - try - { - System.IO.Directory.CreateDirectory(probe); - if (probe != path) - System.IO.Directory.Delete(probe); - return true; - } - catch - { - } - } - - return false; - } - - /// - /// The unique machine installation ID - /// - public static string InstallID - { - get - { - try { return System.IO.File.ReadAllLines(System.IO.Path.Combine(UPDATEDIR, INSTALL_FILE)).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x))?.Trim() ?? ""; } - catch { } - - return ""; - } - } - - /// - /// The unique machine ID - /// - public static string MachineID - { - get - { - string machinedId = null; - try { machinedId = System.IO.File.ReadAllLines(System.IO.Path.Combine(UPDATEDIR, MACHINE_FILE)).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x))?.Trim() ?? ""; } - catch { } - - return string.IsNullOrWhiteSpace(machinedId) - ? InstallID - : machinedId; - } - } - - /// - /// The machine name - /// - public static readonly string MachineName = MachineNameReader.GetMachineName(); /// /// The package type ID /// - public static string PackageTypeId + public static string PackageTypeId => _packageTypeId.Value; + + /// + /// The package type ID, lazy evaluated + /// + private static readonly Lazy _packageTypeId = new(() => { - get - { - try { return System.IO.File.ReadAllLines(System.IO.Path.Combine(INSTALLATIONDIR, PACKAGE_TYPE_FILE)).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x))?.Trim() ?? ""; } - catch { } + try { return File.ReadAllLines(Path.Combine(INSTALLATIONDIR!, PACKAGE_TYPE_FILE)).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x))?.Trim() ?? ""; } + catch { } #if DEBUG - return "debug"; + return "debug"; #else - return ""; + return ""; #endif - } - } + }); - public static UpdateInfo CheckForUpdate(ReleaseType channel = ReleaseType.Unknown) + /// + /// Checks for updates and returns the update information if available + /// + /// The release channel to check for updates + /// The update information if available, or null if no updates are available + public static UpdateInfo? CheckForUpdate(ReleaseType channel = ReleaseType.Unknown) { if (channel == ReleaseType.Unknown) channel = AutoUpdateSettings.DefaultUpdateChannel; @@ -330,20 +214,21 @@ namespace Duplicati.Library.AutoUpdater if (SIGN_KEYS.Length == 0) throw new Exception("No signing keys are available, cannot check update"); - using (var tmpfile = new Library.Utility.TempFile()) + using (var tmpfile = new TempFile()) { using var request = new HttpRequestMessage(HttpMethod.Get, url); - request.Headers.Add(System.Net.HttpRequestHeader.UserAgent.ToString(), string.Format("{0} v{1}{2}", APPNAME, SelfVersion.Version, string.IsNullOrWhiteSpace(InstallID) ? "" : " -" + InstallID)); - request.Headers.Add("X-Install-ID", InstallID); + request.Headers.Add(System.Net.HttpRequestHeader.UserAgent.ToString(), string.Format("{0} v{1}{2}", APPNAME, SelfVersion.Version, string.IsNullOrWhiteSpace(DataFolderManager.InstallID) ? "" : " -" + DataFolderManager.InstallID)); + request.Headers.Add("X-Install-ID", DataFolderManager.InstallID); request.Headers.Add("X-Package-Type-ID", PackageTypeId); using var timeoutToken = new CancellationTokenSource(); timeoutToken.CancelAfter(TimeSpan.FromSeconds(SHORT_OPERATION_TIMEOUT_SECONDS)); - HttpClientHelper.DefaultClient.DownloadFile(request, tmpfile, null, timeoutToken.Token).ConfigureAwait(false).GetAwaiter().GetResult(); + using (var client = HttpClientHelper.CreateClient()) + client.DownloadFile(request, tmpfile, null, timeoutToken.Token).Await(); - using (var fs = System.IO.File.OpenRead(tmpfile)) + using (var fs = File.OpenRead(tmpfile)) { var verifyOps = SIGN_KEYS.Select(k => new JSONSignature.VerifyOperation( Algorithm: JSONSignature.RSA_SHA256, @@ -354,6 +239,8 @@ namespace Duplicati.Library.AutoUpdater throw new Exception("No valid signature found in manifest file"); var update = JsonSerializer.Deserialize(fs); + if (update == null) + return null; if (TryParseVersion(update.Version) <= TryParseVersion(SelfVersion.Version)) return null; @@ -394,10 +281,15 @@ namespace Duplicati.Library.AutoUpdater return null; } - private static UpdateInfo ReadInstalledManifest(string folder) + /// + /// Reads the installed manifest file + /// + /// The folder to read the manifest from + /// The manifest if found, or null if not found + private static UpdateInfo? ReadInstalledManifest(string folder) { - var manifest = System.IO.Path.Combine(folder, UPDATE_MANIFEST_FILENAME); - if (System.IO.File.Exists(manifest)) + var manifest = Path.Combine(folder, UPDATE_MANIFEST_FILENAME); + if (File.Exists(manifest)) { try { @@ -406,7 +298,7 @@ namespace Duplicati.Library.AutoUpdater PublicKey: k.ToXmlString(false) )); - using (var fs = System.IO.File.OpenRead(manifest)) + using (var fs = File.OpenRead(manifest)) { if (!JSONSignature.VerifyAtLeastOne(fs, verifyOps)) throw new Exception("Installed manifest signature is invalid"); @@ -424,11 +316,16 @@ namespace Duplicati.Library.AutoUpdater return null; } - public static bool DownloadUpdate(UpdateInfo version, PackageEntry package, string targetPath, Action progress = null) + /// + /// Downloads the update package + /// + /// The version to download + /// The package to download + /// The path to save the downloaded package to + /// The progress callback + /// True if the download was successful, otherwise false + public static bool DownloadUpdate(UpdateInfo version, PackageEntry package, string targetPath, Action? progress = null) { - if (UPDATEDIR == null) - return false; - var updates = package.RemoteUrls.ToList(); // If alternate update URLs are specified, @@ -455,21 +352,22 @@ namespace Duplicati.Library.AutoUpdater { try { - using (var tempfile = System.IO.File.Open(tempfilename, FileMode.Create, FileAccess.ReadWrite, FileShare.None)) + using (var tempfile = File.Open(tempfilename, FileMode.Create, FileAccess.ReadWrite, FileShare.None)) { - Action cb = null; + Action? cb = null; if (progress != null) cb = (s) => { progress(Math.Min(1.0, Math.Max(0.0, (double)s / package.Length))); }; using var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.Add(System.Net.HttpRequestHeader.UserAgent.ToString(), string.Format("{0} v{1}", APPNAME, SelfVersion.Version)); - request.Headers.Add("X-Install-ID", InstallID); + request.Headers.Add("X-Install-ID", DataFolderManager.InstallID); using var timeoutToken = new CancellationTokenSource(); timeoutToken.CancelAfter(TimeSpan.FromSeconds(DOWNLOAD_OPERATION_TIMEOUT_SECONDS)); - HttpClientHelper.DefaultClient.DownloadFile(request, tempfile, cb, timeoutToken.Token).ConfigureAwait(false).GetAwaiter().GetResult(); + using (var client = HttpClientHelper.CreateClient()) + client.DownloadFile(request, tempfile, cb, timeoutToken.Token).Await(); var sha256 = System.Security.Cryptography.SHA256.Create(); var md5 = System.Security.Cryptography.MD5.Create(); @@ -512,10 +410,11 @@ namespace Duplicati.Library.AutoUpdater /// The version of the manifest /// The URL to use for incompatible updates /// The URL to use for generic updates - public static void CreateSignedManifest(IEnumerable keys, string sourcedata, string outputfolder, string version = null, string incompatibleUpdateUrl = null, string genericUpdatePageUrl = null, string releaseType = null, IEnumerable packages = null) + public static void CreateSignedManifest(IEnumerable keys, string sourcedata, string outputfolder, string? version = null, string? incompatibleUpdateUrl = null, string? genericUpdatePageUrl = null, string? releaseType = null, IEnumerable? packages = null) { // Read the existing manifest - var remoteManifest = JsonSerializer.Deserialize(string.IsNullOrWhiteSpace(sourcedata) ? "{}" : sourcedata); + var remoteManifest = JsonSerializer.Deserialize(string.IsNullOrWhiteSpace(sourcedata) ? "{}" : sourcedata) + ?? throw new Exception("Failed to deserialize the manifest from source data"); if (remoteManifest.ReleaseTime.Ticks == 0) remoteManifest = remoteManifest with { ReleaseTime = DateTime.UtcNow }; diff --git a/Duplicati/Library/Backend/AliyunOSS/AliyunOSSBackend.cs b/Duplicati/Library/Backend/AliyunOSS/AliyunOSSBackend.cs index 158e54cdd..53dcb37b7 100644 --- a/Duplicati/Library/Backend/AliyunOSS/AliyunOSSBackend.cs +++ b/Duplicati/Library/Backend/AliyunOSS/AliyunOSSBackend.cs @@ -22,11 +22,11 @@ using Aliyun.OSS; using Aliyun.OSS.Common; using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; -using Duplicati.Library.Utility; using System; using System.Collections.Generic; using System.IO; using System.Net; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -99,7 +99,8 @@ namespace Duplicati.Library.Backend.AliyunOSS }); } - public IEnumerable List() + + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { var bucketName = _ossOptions.BucketName; @@ -107,51 +108,48 @@ namespace Duplicati.Library.Backend.AliyunOSS var client = GetClient(false); - var list = new List(); - try + var nextMarker = string.Empty; + bool isTruncated; + do { - var nextMarker = string.Empty; - var isTruncated = false; - do + var listObjectsRequest = new ListObjectsRequest(bucketName) { - var listObjectsRequest = new ListObjectsRequest(bucketName) - { - MaxKeys = 1000, - Marker = nextMarker, - Prefix = prefix, - }; - var result = client.ListObjects(listObjectsRequest); + MaxKeys = 1000, + Marker = nextMarker, + Prefix = prefix, + }; + + ObjectListing result; + try + { + result = await Task.Run(() => client.ListObjects(listObjectsRequest), cancelToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); if (result.HttpStatusCode != HttpStatusCode.OK) { throw new Exception(result.HttpStatusCode.ToString()); } - foreach (var summary in result.ObjectSummaries) - { - list.Add(summary); - } + } + catch (OssException ex) + { + Logging.Log.WriteErrorMessage(LOGTAG, "List", ex, "List object failed. {0}, {1}", ex.Message, ex.ErrorCode); + throw; + } + catch (Exception ex) + { + Logging.Log.WriteErrorMessage(LOGTAG, "List", ex, "List object failed. {0}", ex.Message); + throw; + } - nextMarker = result.NextMarker; - isTruncated = result.IsTruncated; - } while (isTruncated); - } - catch (OssException ex) - { - Logging.Log.WriteErrorMessage(LOGTAG, "List", ex, "List object failed. {0}, {1}", ex.Message, ex.ErrorCode); - throw; - } - catch (Exception ex) - { - Logging.Log.WriteErrorMessage(LOGTAG, "List", ex, "List object failed. {0}", ex.Message); - throw; - } + foreach (var summary in result.ObjectSummaries) + { + var fileName = Path.GetFileName(summary.Key); + var time = summary.LastModified; // DateTimeOffset.Parse(summary.LastModified).ToLocalTime().DateTime; + yield return new FileEntry(fileName, summary.Size, time, time); + } - foreach (var item in list) - { - var fileName = Path.GetFileName(item.Key); - var time = item.LastModified; // DateTimeOffset.Parse(item.LastModified).ToLocalTime().DateTime; - yield return new FileEntry(fileName, item.Size, time, time); - } + nextMarker = result.NextMarker; + isTruncated = result.IsTruncated; + } while (isTruncated); } public async Task PutAsync(string remotename, string filename, CancellationToken cancelToken) diff --git a/Duplicati/Library/Backend/AzureBlob/AzureBlobBackend.cs b/Duplicati/Library/Backend/AzureBlob/AzureBlobBackend.cs index 1eb5f5bcb..d82962eed 100644 --- a/Duplicati/Library/Backend/AzureBlob/AzureBlobBackend.cs +++ b/Duplicati/Library/Backend/AzureBlob/AzureBlobBackend.cs @@ -19,12 +19,9 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +using Azure; +using Azure.Storage.Blobs.Models; using Duplicati.Library.Interface; -using Microsoft.Azure.Storage.Shared.Protocol; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; namespace Duplicati.Library.Backend.AzureBlob { @@ -77,33 +74,22 @@ namespace Duplicati.Library.Backend.AzureBlob { throw new UserInformationException(Strings.AzureBlobBackend.NoAccessKeyOrSasToken, "AzureNoAccessKeyOrSasToken"); } - - + _azureBlob = new AzureBlobWrapper(storageAccountName, accessKey, sasToken, containerName); } - public string DisplayName - { - get { return Strings.AzureBlobBackend.DisplayName; } - } + public string DisplayName => Strings.AzureBlobBackend.DisplayName; - public string ProtocolKey - { - get { return "azure"; } - } + public string ProtocolKey => "azure"; - public IEnumerable List() - { - return _azureBlob.ListContainerEntries(); - } + public IAsyncEnumerable ListAsync(CancellationToken cancelToken) + => _azureBlob.ListContainerEntriesAsync(cancelToken); public async Task PutAsync(string remotename, string localname, CancellationToken cancelToken) { - using (var fs = File.Open(localname, - FileMode.Open, FileAccess.Read, FileShare.Read)) - { - await PutAsync(remotename, fs, cancelToken); - } + await using var fs = File.Open(localname, + FileMode.Open, FileAccess.Read, FileShare.Read); + await PutAsync(remotename, fs, cancelToken).ConfigureAwait(false); } public Task PutAsync(string remotename, Stream input, CancellationToken cancelToken) @@ -113,12 +99,10 @@ namespace Duplicati.Library.Backend.AzureBlob public async Task GetAsync(string remotename, string localname, CancellationToken cancellationToken) { - using (var fs = File.Open(localname, + await using var fs = File.Open(localname, FileMode.Create, FileAccess.Write, - FileShare.None)) - { - await GetAsync(remotename, fs, cancellationToken).ConfigureAwait(false); - } + FileShare.None); + await GetAsync(remotename, fs, cancellationToken).ConfigureAwait(false); } public Task GetAsync(string remotename, Stream output, CancellationToken cancellationToken) @@ -161,42 +145,38 @@ namespace Duplicati.Library.Backend.AzureBlob } } - public string Description - { - get - { - return Strings.AzureBlobBackend.Description_v2; - } - } + public string Description => Strings.AzureBlobBackend.DescriptionV2; public Task GetDNSNamesAsync(CancellationToken cancelToken) => Task.FromResult(_azureBlob.DnsNames); public Task TestAsync(CancellationToken cancellationToken) - { - return WrapWithExceptionHandler(Task.Run(() => this.TestList())); - } + => this.TestListAsync(cancellationToken); public Task CreateFolderAsync(CancellationToken cancellationToken) { return WrapWithExceptionHandler(_azureBlob.AddContainerAsync(cancellationToken)); } - + + /// + /// Wraps the task with exception handling + /// private async Task WrapWithExceptionHandler(Task task) { try { await task.ConfigureAwait(false); } - catch (Microsoft.WindowsAzure.Storage.StorageException e) - when (e.RequestInformation.HttpStatusCode == 404 - || e.RequestInformation.HttpStatusMessage == StorageErrorCodeStrings.ResourceNotFound) + catch (RequestFailedException e) + when (e.Status == 404 + || e.ErrorCode == BlobErrorCode.BlobNotFound + || e.ErrorCode == BlobErrorCode.ResourceNotFound) { throw new FileMissingException(e.Message, e); } - catch (Microsoft.WindowsAzure.Storage.StorageException e) - when (e.RequestInformation.HttpStatusMessage == StorageErrorCodeStrings.ContainerNotFound - || e.RequestInformation.HttpStatusMessage == StorageErrorCodeStrings.ContainerBeingDeleted - || e.RequestInformation.HttpStatusMessage == StorageErrorCodeStrings.ContainerDisabled) + catch (RequestFailedException e) + when (e.ErrorCode == BlobErrorCode.ContainerNotFound + || e.ErrorCode == BlobErrorCode.ContainerBeingDeleted + || e.ErrorCode == BlobErrorCode.ContainerDisabled) { throw new FolderMissingException(e.Message, e); } diff --git a/Duplicati/Library/Backend/AzureBlob/AzureBlobWrapper.cs b/Duplicati/Library/Backend/AzureBlob/AzureBlobWrapper.cs index b18290ae6..769d06f21 100644 --- a/Duplicati/Library/Backend/AzureBlob/AzureBlobWrapper.cs +++ b/Duplicati/Library/Backend/AzureBlob/AzureBlobWrapper.cs @@ -19,16 +19,12 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using System.Runtime.CompilerServices; using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; -using Duplicati.Library.Utility; -using Microsoft.WindowsAzure.Storage; -using Microsoft.WindowsAzure.Storage.Blob; +using Uri = System.Uri; namespace Duplicati.Library.Backend.AzureBlob { @@ -37,142 +33,133 @@ namespace Duplicati.Library.Backend.AzureBlob /// public class AzureBlobWrapper { - private readonly string _containerName; - private readonly CloudBlobContainer _container; - private readonly OperationContext _operationContext; - - // Note: May need metadata; need to test with Azure blobs - private const BlobListingDetails ListDetails = BlobListingDetails.None; + private readonly BlobContainerClient _container; + /// + /// Gets an array of DNS names associated with the blob container. + /// + /// An array of DNS hostnames for the primary and secondary URIs of the container. public string[] DnsNames { get { var lst = new List(); - if (_container != null) - { - if (_container.Uri != null) - lst.Add(_container.Uri.Host); - - if (_container.StorageUri != null) - { - if (_container.StorageUri.PrimaryUri != null) - lst.Add(_container.StorageUri.PrimaryUri.Host); - if (_container.StorageUri.SecondaryUri != null) - lst.Add(_container.StorageUri.SecondaryUri.Host); - } - } - + if (_container != null && _container.Uri != null) lst.Add(_container.Uri.Host); return lst.ToArray(); } } + /// + /// Initializes a new instance of the AzureBlobWrapper class. + /// + /// The Azure storage account name. + /// The access key for the storage account. + /// The Shared Access Signature (SAS) token for authentication. + /// The name of the blob container. public AzureBlobWrapper(string accountName, string accessKey, string sasToken, string containerName) { - _operationContext = new() - { - CustomUserAgent = string.Format( - "APN/1.0 Duplicati/{0} AzureBlob/2.0 {1}", - System.Reflection.Assembly.GetExecutingAssembly().GetName().Version, - Microsoft.WindowsAzure.Storage.Shared.Protocol.Constants.HeaderConstants.UserAgent - ) - }; - - string connectionString; + BlobServiceClient blobServiceClient; if (sasToken != null) { - connectionString = string.Format("DefaultEndpointsProtocol=https;AccountName={0};SharedAccessSignature={1}", - accountName, sasToken); + var sasUri = new Uri($"https://{accountName}.blob.core.windows.net/?{sasToken}"); + blobServiceClient = new BlobServiceClient(sasUri); } else { - connectionString = string.Format("DefaultEndpointsProtocol=https;AccountName={0};AccountKey={1}", - accountName, accessKey); + var connectionString = $"DefaultEndpointsProtocol=https;AccountName={accountName};AccountKey={accessKey};EndpointSuffix=core.windows.net"; + blobServiceClient = new BlobServiceClient(connectionString); } - var storageAccount = CloudStorageAccount.Parse(connectionString); - var blobClient = storageAccount.CreateCloudBlobClient(); - - _containerName = containerName; - _container = blobClient.GetContainerReference(containerName); + _container = blobServiceClient.GetBlobContainerClient(containerName); } + /// + /// Creates a new blob container asynchronously and sets its access permissions to private. + /// + /// A token to monitor for cancellation requests. + /// A task that represents the asynchronous operation. public async Task AddContainerAsync(CancellationToken cancellationToken) { - await _container.CreateAsync(default, default, _operationContext, cancellationToken).ConfigureAwait(false); - await _container.SetPermissionsAsync(new BlobContainerPermissions { PublicAccess = BlobContainerPublicAccessType.Off }, default, default, _operationContext, cancellationToken).ConfigureAwait(false); + // Even though PublicAccessType.None is by default, we set it explicitly to highlight it. + await _container.CreateAsync(PublicAccessType.None, cancellationToken: cancellationToken).ConfigureAwait(false); } - public virtual Task GetFileStreamAsync(string keyName, Stream target, CancellationToken cancellationToken) + /// + /// Downloads a blob to a stream asynchronously. + /// + /// The name of the blob to download. + /// The stream to download the blob to. + /// A token to monitor for cancellation requests. + /// A task that represents the asynchronous download operation. + public async Task GetFileStreamAsync(string keyName, Stream target, CancellationToken cancellationToken) { - return _container.GetBlockBlobReference(keyName).DownloadToStreamAsync(target, default, default, _operationContext, cancellationToken); + var blobClient = _container.GetBlobClient(keyName); + await blobClient.DownloadToAsync(target, cancellationToken).ConfigureAwait(false); } - public virtual Task AddFileStream(string keyName, Stream source, CancellationToken cancelToken) + /// + /// Uploads a stream to a blob asynchronously. + /// + /// The name to give the uploaded blob. + /// The stream containing the data to upload. + /// A token to monitor for cancellation requests. + /// A task that represents the asynchronous upload operation. + public async Task AddFileStream(string keyName, Stream source, CancellationToken cancelToken) { - return _container.GetBlockBlobReference(keyName).UploadFromStreamAsync(source, source.Length, default, default, _operationContext, cancelToken); + var blobClient = _container.GetBlobClient(keyName); + await blobClient.UploadAsync(source, true, cancelToken).ConfigureAwait(false); } - public Task DeleteObjectAsync(string keyName, CancellationToken cancelToken) + /// + /// Deletes a blob if it exists asynchronously. + /// + /// The name of the blob to delete. + /// A token to monitor for cancellation requests. + public async Task DeleteObjectAsync(string keyName, CancellationToken cancelToken) { - return _container.GetBlockBlobReference(keyName).DeleteIfExistsAsync(default, default, default, _operationContext, cancelToken); + var blobClient = _container.GetBlobClient(keyName); + await blobClient.DeleteIfExistsAsync(cancellationToken: cancelToken).ConfigureAwait(false); } - - private async Task> ListContainerEntriesAsync() + + /// + /// List container files. + /// + /// A token to monitor for cancellation requests. + /// + /// Thrown when the container is not found + public virtual async IAsyncEnumerable ListContainerEntriesAsync([EnumeratorCancellation] CancellationToken cancelToken) { - var segment = await _container.ListBlobsSegmentedAsync(null, false, ListDetails, null, null, null, _operationContext); - var list = new List(); - - list.AddRange(segment.Results); - - while (segment.ContinuationToken != null) + await using var blobEnumerator = _container.GetBlobsAsync().GetAsyncEnumerator(cancelToken); + + while (true) { - segment = await _container.ListBlobsSegmentedAsync(null, false, ListDetails, null, segment.ContinuationToken, null, _operationContext); - list.AddRange(segment.Results); - } - - return list; - } - - public virtual List ListContainerEntries() - { - var listBlobItems = ListContainerEntriesAsync().GetAwaiter().GetResult(); - try - { - return listBlobItems.Select(x => + bool hasNext; + + try { - var absolutePath = x.StorageUri.PrimaryUri.AbsolutePath; - var containerSegment = string.Concat("/", _containerName, "/"); - var blobName = absolutePath.Substring(absolutePath.IndexOf( - containerSegment, System.StringComparison.Ordinal) + containerSegment.Length); - - try - { - if (x is CloudBlockBlob cb) - { - var lastModified = new System.DateTime(); - if (cb.Properties.LastModified != null) - lastModified = new System.DateTime(cb.Properties.LastModified.Value.Ticks, System.DateTimeKind.Utc); - return new FileEntry(Uri.UrlDecode(blobName.Replace("+", "%2B")), cb.Properties.Length, lastModified, lastModified); - } - } - catch - { - // If the metadata fails to parse, return the basic entry - } - - return new FileEntry(Uri.UrlDecode(blobName.Replace("+", "%2B"))); - }) - .Cast() - .ToList(); - } - catch (StorageException ex) - { - if (ex.RequestInformation.HttpStatusCode == 404) + hasNext = await blobEnumerator.MoveNextAsync().ConfigureAwait(false); + } + catch (Azure.RequestFailedException ex) when (ex.Status == 404) { throw new FolderMissingException(ex); } - throw; + + if (!hasNext) break; + + cancelToken.ThrowIfCancellationRequested(); + + if (blobEnumerator.Current is { } blob) + { + var blobName = Uri.UnescapeDataString(blob.Name.Replace("+", "%2B")); + var lastModified = blob.Properties.LastModified?.UtcDateTime ?? DateTime.UtcNow; + + yield return new FileEntry( + blobName, + blob.Properties.ContentLength ?? 0, + lastModified, + lastModified + ); + } } } } diff --git a/Duplicati/Library/Backend/AzureBlob/Duplicati.Library.Backend.AzureBlob.csproj b/Duplicati/Library/Backend/AzureBlob/Duplicati.Library.Backend.AzureBlob.csproj index 0b52f52bb..f3de97e26 100644 --- a/Duplicati/Library/Backend/AzureBlob/Duplicati.Library.Backend.AzureBlob.csproj +++ b/Duplicati/Library/Backend/AzureBlob/Duplicati.Library.Backend.AzureBlob.csproj @@ -4,18 +4,11 @@ net8.0 Library Copyright © 2025 Team Duplicati, MIT license + enable - - - - - - - - - - + + diff --git a/Duplicati/Library/Backend/AzureBlob/Strings.cs b/Duplicati/Library/Backend/AzureBlob/Strings.cs index d877ff22b..e0693f8d2 100644 --- a/Duplicati/Library/Backend/AzureBlob/Strings.cs +++ b/Duplicati/Library/Backend/AzureBlob/Strings.cs @@ -20,23 +20,24 @@ // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Localization.Short; -namespace Duplicati.Library.Backend.AzureBlob.Strings { - internal static class AzureBlobBackend { - public static string Description_v2 { get { return LC.L(@"This backend can read and write data to Azure blob storage. Allowed format is ""azure://bucketname""."); } } - public static string DisplayName { get { return LC.L(@"Azure blob"); } } - public static string ContainerNameDescriptionLong { get { return LC.L(@"All files will be written to the container specified."); } } - public static string ContainerNameDescriptionShort { get { return LC.L(@"The name of the storage container"); } } - public static string NoStorageAccountName { get { return LC.L(@"No Azure storage account name given"); } } - public static string StorageAccountNameDescriptionLong { get { return LC.L(@"The Azure storage account name which can be obtained by clicking the ""Manage Access Keys"" button on the storage account dashboard."); } } - public static string StorageAccountNameDescriptionShort { get { return LC.L(@"The storage account name"); } } - public static string AccessKeyDescriptionLong { get { return LC.L(@"The Azure access key which can be obtained by clicking the ""Manage Access Keys"" button on the storage account dashboard."); } } - public static string AccessKeyDescriptionShort { get { return LC.L(@"The access key"); } } - public static string SasTokenDescriptionLong { get { return LC.L(@"The Azure shared access signature (SAS) token which can be obtained by selecting the ""Shared access signature"" blade on the storage account dashboard, or inside a container blade."); } } - public static string SasTokenDescriptionShort { get { return LC.L(@"The SAS token"); } } - public static string NoAccessKeyOrSasToken { get { return LC.L(@"No Azure access key or SAS token given"); } } - public static string AuthPasswordDescriptionLong { get { return LC.L(@"The password used to connect to the server. This may also be supplied as the environment variable ""AUTH_PASSWORD""."); } } - public static string AuthPasswordDescriptionShort { get { return LC.L(@"Supply the password used to connect to the server"); } } - public static string AuthUsernameDescriptionLong { get { return LC.L(@"The username used to connect to the server. This may also be supplied as the environment variable ""AUTH_USERNAME""."); } } - public static string AuthUsernameDescriptionShort { get { return LC.L(@"Supply the username used to connect to the server"); } } - } -} + +namespace Duplicati.Library.Backend.AzureBlob.Strings; + +internal static class AzureBlobBackend { + public static string DescriptionV2 => LC.L(@"This backend can read and write data to Azure blob storage. Allowed format is ""azure://bucketname""."); + public static string DisplayName => LC.L(@"Azure blob"); + public static string ContainerNameDescriptionLong => LC.L(@"All files will be written to the container specified."); + public static string ContainerNameDescriptionShort => LC.L(@"The name of the storage container"); + public static string NoStorageAccountName => LC.L(@"No Azure storage account name given"); + public static string StorageAccountNameDescriptionLong => LC.L(@"The Azure storage account name which can be obtained by clicking the ""Manage Access Keys"" button on the storage account dashboard."); + public static string StorageAccountNameDescriptionShort => LC.L(@"The storage account name"); + public static string AccessKeyDescriptionLong => LC.L(@"The Azure access key which can be obtained by clicking the ""Manage Access Keys"" button on the storage account dashboard."); + public static string AccessKeyDescriptionShort => LC.L(@"The access key"); + public static string SasTokenDescriptionLong => LC.L(@"The Azure shared access signature (SAS) token which can be obtained by selecting the ""Shared access signature"" blade on the storage account dashboard, or inside a container blade."); + public static string SasTokenDescriptionShort => LC.L(@"The SAS token"); + public static string NoAccessKeyOrSasToken => LC.L(@"No Azure access key or SAS token given"); + public static string AuthPasswordDescriptionLong => LC.L(@"The password used to connect to the server. This may also be supplied as the environment variable ""AUTH_PASSWORD""."); + public static string AuthPasswordDescriptionShort => LC.L(@"Supply the password used to connect to the server"); + public static string AuthUsernameDescriptionLong => LC.L(@"The username used to connect to the server. This may also be supplied as the environment variable ""AUTH_USERNAME""."); + public static string AuthUsernameDescriptionShort => LC.L(@"Supply the username used to connect to the server"); +} \ No newline at end of file diff --git a/Duplicati/Library/Backend/Backblaze/B2.cs b/Duplicati/Library/Backend/Backblaze/B2.cs index d07ca477f..4ed35e91b 100644 --- a/Duplicati/Library/Backend/Backblaze/B2.cs +++ b/Duplicati/Library/Backend/Backblaze/B2.cs @@ -1,30 +1,33 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; using Duplicati.Library.Utility; using System.Net; +using System.Net.Http.Headers; using System.Reflection; using Duplicati.Library.Backend.Backblaze.Model; +using FileEntry = Duplicati.Library.Common.IO.FileEntry; +using System.Runtime.CompilerServices; namespace Duplicati.Library.Backend.Backblaze; @@ -78,6 +81,11 @@ public class B2 : IStreamingBackend, ITimeoutExemptBackend /// private const int B2_RECOMMENDED_CHUNK_SIZE = 100 * 1024 * 1024; + /// + /// Default retry-after time in seconds for B2 API requests + /// + private const int B2_RETRY_AFTER_SECONDS = 5; + /// /// The name of the B2 bucket being accessed /// @@ -264,7 +272,7 @@ public class B2 : IStreamingBackend, ITimeoutExemptBackend _uploadUrl = await _b2AuthHelper.PostAndGetJsonDataAsync( $"{_b2AuthHelper.ApiUrl}/b2api/v1/b2_get_upload_url", - new UploadUrlRequest { BucketID = GetBucketAsync(cancellationToken).Await().BucketID }, + new UploadUrlRequest { BucketID = (await GetBucketAsync(cancellationToken)).BucketID }, combinedCancellationToken.Token ).ConfigureAwait(false); @@ -275,14 +283,15 @@ public class B2 : IStreamingBackend, ITimeoutExemptBackend /// Retrieves the fileID searching by filename. /// /// Filename + /// Cancellation Token /// /// Exception thrown when file is not found - private string GetFileId(string filename) + private async Task GetFileId(string filename, CancellationToken cancelToken) { if (_filecache != null && _filecache.TryGetValue(filename, out var value)) return value.OrderByDescending(x => x.UploadTimestamp).First().FileID; - List(); + await RebuildFileCache(cancelToken).ConfigureAwait(false); if (_filecache != null && _filecache.TryGetValue(filename, out var value1)) return value1.OrderByDescending(x => x.UploadTimestamp).First().FileID; @@ -350,7 +359,7 @@ public class B2 : IStreamingBackend, ITimeoutExemptBackend } if (_filecache == null) - await ListAsync(cancelToken).ConfigureAwait(false); + await RebuildFileCache(cancelToken).ConfigureAwait(false); var uploadUrlData = await GetUploadUrlDataAsync(cancelToken).ConfigureAwait(false); try @@ -419,11 +428,11 @@ public class B2 : IStreamingBackend, ITimeoutExemptBackend { if (_filecache == null || !_filecache.ContainsKey(remotename)) - await ListAsync(cancellationToken).ConfigureAwait(false); + await RebuildFileCache(cancellationToken).ConfigureAwait(false); using var request = _filecache != null && _filecache.ContainsKey(remotename) ? _b2AuthHelper.CreateRequest( - $"{DownloadUrl}/b2api/v1/b2_download_file_by_id?fileId={Utility.Uri.UrlEncode(GetFileId(remotename))}") + $"{DownloadUrl}/b2api/v1/b2_download_file_by_id?fileId={Utility.Uri.UrlEncode(await GetFileId(remotename, cancellationToken))}") : _b2AuthHelper.CreateRequest( $"{DownloadUrl}/{_urlencodedPrefix}{Utility.Uri.UrlPathEncode(remotename)}"); @@ -455,70 +464,89 @@ public class B2 : IStreamingBackend, ITimeoutExemptBackend /// Implementation of interface method for listing remote folder contents /// /// List of IFileEntry with directory listing result - private async Task> ListAsync(CancellationToken cancellationToken) + private async Task RebuildFileCache(CancellationToken cancellationToken) { _filecache = null; var cache = new Dictionary>(); string nextFileId = null; string nextFileName = null; + string listVersionUrl = $"{_b2AuthHelper.ApiUrl}/b2api/v1/b2_list_file_versions"; + + var listRetryHelper = RetryAfterHelper.CreateOrGetRetryAfterHelper(listVersionUrl); + do { - - using var timeoutToken = new CancellationTokenSource(); - timeoutToken.CancelAfter(TimeSpan.FromSeconds(SHORT_OPERATION_TIMEOUT_SECONDS)); - using var combinedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(timeoutToken.Token, cancellationToken); - - var resp = await _b2AuthHelper.PostAndGetJsonDataAsync( - $"{_b2AuthHelper.ApiUrl}/b2api/v1/b2_list_file_versions", - new ListFilesRequest - { - BucketID = GetBucketAsync(cancellationToken).Await().BucketID, - MaxFileCount = _pageSize, - Prefix = _prefix, - StartFileID = nextFileId, - StartFileName = nextFileName - }, - combinedCancellationToken.Token - ).ConfigureAwait(false); - - nextFileId = resp.NextFileID; - nextFileName = resp.NextFileName; - - if (resp.Files == null || resp.Files.Length == 0) - break; - - foreach (var file in resp.Files) + try { - if (!file.FileName.StartsWith(_prefix, StringComparison.Ordinal)) - continue; + using var timeoutToken = new CancellationTokenSource(); + timeoutToken.CancelAfter(TimeSpan.FromSeconds(SHORT_OPERATION_TIMEOUT_SECONDS)); + using var combinedCancellationToken = + CancellationTokenSource.CreateLinkedTokenSource(timeoutToken.Token, cancellationToken); - var name = file.FileName[_prefix.Length..]; - if (name.Contains('/')) - continue; + var resp = await _b2AuthHelper.PostAndGetJsonDataAsync( + $"{_b2AuthHelper.ApiUrl}/b2api/v1/b2_list_file_versions", + new ListFilesRequest + { + BucketID = (await GetBucketAsync(cancellationToken)).BucketID, + MaxFileCount = _pageSize, + Prefix = _prefix, + StartFileID = nextFileId, + StartFileName = nextFileName + }, + combinedCancellationToken.Token + ).ConfigureAwait(false); - cache.TryGetValue(name, out var lst); - if (lst == null) - cache[name] = lst = new List(1); - lst.Add(file); + nextFileId = resp.NextFileID; + nextFileName = resp.NextFileName; + + if (resp.Files == null || resp.Files.Length == 0) + break; + + foreach (var file in resp.Files) + { + if (!file.FileName.StartsWith(_prefix, StringComparison.Ordinal)) + continue; + + var name = file.FileName[_prefix.Length..]; + if (name.Contains('/')) + continue; + + cache.TryGetValue(name, out var lst); + if (lst == null) + cache[name] = lst = new System.Collections.Generic.List(1); + lst.Add(file); + } } + catch (TooManyRequestException tex) + { + // Backblaze's B2 API rate limit reached. Presently they don't add Retry-After headers, we will default to a delay, but respect it if present. + if (tex.RetryAfter == null || (tex.RetryAfter.Date == null && tex.RetryAfter.Delta == null)) + listRetryHelper.SetRetryAfter( + new RetryConditionHeaderValue(TimeSpan.FromSeconds(B2_RETRY_AFTER_SECONDS))); + else + listRetryHelper.SetRetryAfter(tex.RetryAfter); + await listRetryHelper.WaitForRetryAfterAsync(cancellationToken); + } } while (nextFileId != null); _filecache = cache; - - return - (from x in _filecache - let newest = x.Value.OrderByDescending(y => y.UploadTimestamp).First() - let ts = Utility.Utility.EPOCH.AddMilliseconds(newest.UploadTimestamp) - select (IFileEntry)new FileEntry(x.Key, newest.Size, ts, ts) - ).ToList(); } /// - /// Wrapper method of legacy non async call to list files in the remote folder + /// Implementation of interface method for listing remote folder contents /// - /// - public IEnumerable List() => ListAsync(CancellationToken.None).Await(); + /// List of IFileEntry with directory listing result + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await RebuildFileCache(cancellationToken).ConfigureAwait(false); + foreach (var x in _filecache) + { + var newest = x.Value.OrderByDescending(y => y.UploadTimestamp).First(); + var ts = Utility.Utility.EPOCH.AddMilliseconds(newest.UploadTimestamp); + yield return new FileEntry(x.Key, newest.Size, ts, ts); + } + } // public async Task PutAsync(string remotename, string filename, CancellationToken cancelToken) @@ -558,7 +586,7 @@ public class B2 : IStreamingBackend, ITimeoutExemptBackend using var combinedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(timeoutToken.Token, cancellationToken); if (_filecache == null || !_filecache.ContainsKey(remotename)) - await ListAsync(cancellationToken).ConfigureAwait(false); + await RebuildFileCache(cancellationToken).ConfigureAwait(false); if (!_filecache.TryGetValue(remotename, out var value)) throw new FileMissingException(); @@ -588,10 +616,7 @@ public class B2 : IStreamingBackend, ITimeoutExemptBackend /// /// Cancellation Token public Task TestAsync(CancellationToken cancelToken) - { - this.TestList(); - return Task.CompletedTask; - } + => this.TestListAsync(cancelToken); /// /// Create remote folder diff --git a/Duplicati/Library/Backend/Backblaze/B2AuthHelper.cs b/Duplicati/Library/Backend/Backblaze/B2AuthHelper.cs index 6c9003a66..18ad6113a 100644 --- a/Duplicati/Library/Backend/Backblaze/B2AuthHelper.cs +++ b/Duplicati/Library/Backend/Backblaze/B2AuthHelper.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System.Net; @@ -202,6 +202,9 @@ public class B2AuthHelper(string userid, string password, HttpClient httpClient) if (ex is not HttpRequestException || responseContext == null) return; + if (ex is HttpRequestException && responseContext != null && responseContext.StatusCode == HttpStatusCode.TooManyRequests) + throw new TooManyRequestException(responseContext.Headers.RetryAfter); + using var stream = responseContext.Content.ReadAsStream(); using var reader = new StreamReader(stream); var rawData = reader.ReadToEnd(); diff --git a/Duplicati/Library/Backend/Backblaze/Duplicati.Library.Backend.Backblaze.csproj b/Duplicati/Library/Backend/Backblaze/Duplicati.Library.Backend.Backblaze.csproj index fbdff62cf..b97001bf1 100644 --- a/Duplicati/Library/Backend/Backblaze/Duplicati.Library.Backend.Backblaze.csproj +++ b/Duplicati/Library/Backend/Backblaze/Duplicati.Library.Backend.Backblaze.csproj @@ -8,6 +8,7 @@ + diff --git a/Duplicati/Library/Backend/Box/BoxBackend.cs b/Duplicati/Library/Backend/Box/BoxBackend.cs index 98c8c50cb..d256255d9 100644 --- a/Duplicati/Library/Backend/Box/BoxBackend.cs +++ b/Duplicati/Library/Backend/Box/BoxBackend.cs @@ -1,32 +1,32 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; -using Duplicati.Library.Utility; using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Linq; using System.Net; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -133,7 +133,7 @@ namespace Duplicati.Library.Backend.Box foreach (var p in m_path.Split(new string[] { "/" }, StringSplitOptions.RemoveEmptyEntries)) { - var el = (MiniFolder)PagedFileListResponse(parentid, true).FirstOrDefault(x => x.Name == p); + var el = (MiniFolder)await PagedFileListResponse(parentid, true, cancelToken).FirstOrDefaultAsync(x => x.Name == p).ConfigureAwait(false); if (el == null) { if (!create) @@ -159,7 +159,7 @@ namespace Duplicati.Library.Backend.Box // Make sure we enumerate this, otherwise the m_filecache is empty. var currentFolder = await GetCurrentFolderWithCacheAsync(cancelToken).ConfigureAwait(false); - PagedFileListResponse(currentFolder, false).LastOrDefault(); + await PagedFileListResponse(currentFolder, false, cancelToken).LastOrDefaultAsync().ConfigureAwait(false); if (m_filecache.ContainsKey(name)) return m_filecache[name]; @@ -167,7 +167,7 @@ namespace Duplicati.Library.Backend.Box throw new FileMissingException(); } - private IEnumerable PagedFileListResponse(string parentid, bool onlyfolders) + private async IAsyncEnumerable PagedFileListResponse(string parentid, bool onlyfolders, [EnumeratorCancellation] CancellationToken cancelToken) { var offset = 0; var done = false; @@ -177,7 +177,7 @@ namespace Duplicati.Library.Backend.Box do { - var resp = m_oauth.GetJSONData(string.Format("{0}/folders/{1}/items?limit={2}&offset={3}&fields=name,size,modified_at", BOX_API_URL, parentid, PAGE_SIZE, offset)); + var resp = await m_oauth.GetJSONDataAsync($"{BOX_API_URL}/folders/{parentid}/items?limit={PAGE_SIZE}&offset={offset}&fields=name,size,modified_at", cancelToken).ConfigureAwait(false); if (resp.Entries == null || resp.Entries.Length == 0) break; @@ -221,7 +221,7 @@ namespace Duplicati.Library.Backend.Box }; if (m_filecache.Count == 0) - PagedFileListResponse(currentFolder, false); + await PagedFileListResponse(currentFolder, false, cancelToken).LastOrDefaultAsync().ConfigureAwait(false); var existing = m_filecache.ContainsKey(remotename); @@ -262,12 +262,11 @@ namespace Duplicati.Library.Backend.Box #region IBackend implementation - public System.Collections.Generic.IEnumerable List() + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { - var currentFolder = GetCurrentFolderWithCacheAsync(CancellationToken.None).Await(); - return - from n in PagedFileListResponse(currentFolder, false) - select (IFileEntry)new FileEntry(n.Name, n.Size, n.ModifiedAt, n.ModifiedAt) { IsFolder = n.Type == "folder" }; + var currentFolder = await GetCurrentFolderWithCacheAsync(cancelToken).ConfigureAwait(false); + await foreach (var n in PagedFileListResponse(currentFolder, false, cancelToken).ConfigureAwait(false)) + yield return new FileEntry(n.Name, n.Size, n.ModifiedAt, n.ModifiedAt) { IsFolder = n.Type == "folder" }; } public async Task PutAsync(string remotename, string filename, CancellationToken cancelToken) @@ -304,10 +303,7 @@ namespace Duplicati.Library.Backend.Box } public Task TestAsync(CancellationToken cancelToken) - { - this.TestList(); - return Task.CompletedTask; - } + => this.TestListAsync(cancelToken); public Task CreateFolderAsync(CancellationToken cancellationToken) { diff --git a/Duplicati/Library/Backend/Box/Duplicati.Library.Backend.Box.csproj b/Duplicati/Library/Backend/Box/Duplicati.Library.Backend.Box.csproj index 261a7a42f..d9cbc0442 100644 --- a/Duplicati/Library/Backend/Box/Duplicati.Library.Backend.Box.csproj +++ b/Duplicati/Library/Backend/Box/Duplicati.Library.Backend.Box.csproj @@ -8,6 +8,7 @@ + diff --git a/Duplicati/Library/Backend/CIFS/CIFS.cs b/Duplicati/Library/Backend/CIFS/CIFS.cs index 815773fad..53ec3264e 100644 --- a/Duplicati/Library/Backend/CIFS/CIFS.cs +++ b/Duplicati/Library/Backend/CIFS/CIFS.cs @@ -28,15 +28,18 @@ using System.Threading; using System.Threading.Tasks; using Duplicati.Library.Backend.CIFS; using Duplicati.Library.Backend.CIFS.Model; -using Duplicati.Library.Utility; using SMBLibrary; +using Duplicati.Library.SourceProvider; +using System.Runtime.CompilerServices; +using Duplicati.Library.Common.IO; +using System.Runtime.CompilerServices; namespace Duplicati.Library.Backend; /// /// Native CIFS/SMB Backend implementation /// -public class CIFSBackend : IStreamingBackend +public class CIFSBackend : IStreamingBackend, IFolderEnabledBackend { /// /// Implementation of interface property for the backend key @@ -72,12 +75,12 @@ public class CIFSBackend : IStreamingBackend /// Shared connection between all methods to avoid re-authentication /// private SMBShareConnection _sharedConnection; - + /// /// Read buffer size for SMB operations (will be capped automatically by SMB negotiated values) /// private const string READ_BUFFER_SIZE_OPTION = "read-buffer-size"; - + /// /// Write buffer size for SMB operations (will be capped automatically by SMB negotiated values) /// @@ -139,33 +142,33 @@ public class CIFSBackend : IStreamingBackend var uri = new Utility.Uri(url); uri.RequireHost(); _DnsName = uri.Host; - + var input = uri.Path.TrimEnd('/'); var slashIndex = input.IndexOf('/'); // Find first slash to separate server and share if present. - + options.TryGetValue(AUTH_USERNAME_OPTION, out string authUsername); options.TryGetValue(AUTH_PASSWORD_OPTION, out string authPassword); options.TryGetValue(AUTH_DOMAIN_OPTION, out string authDomain); options.TryGetValue(TRANSPORT_OPTION, out string transport); - + int? readBufferSize = null, writeBufferSize = null; - + options.TryGetValue(READ_BUFFER_SIZE_OPTION, out string readBufferSizeConfig); if (!string.IsNullOrWhiteSpace(readBufferSizeConfig)) readBufferSize = Int32.TryParse(readBufferSizeConfig, out int value) ? value : null; - + options.TryGetValue(WRITE_BUFFER_SIZE_OPTION, out string writeBufferSizeConfig); if (!string.IsNullOrWhiteSpace(writeBufferSizeConfig)) writeBufferSize = Int32.TryParse(readBufferSizeConfig, out int value) ? value : null; - + // Normalize to 10KB minimum buffers size readBufferSize = readBufferSize < 1024 * 10 ? null : readBufferSize; writeBufferSize = writeBufferSize < 1024 * 10 ? null : writeBufferSize; SMBTransportType transportType = _transportMap.TryGetValue( - string.IsNullOrEmpty(transport) ? DEFAULT_TRANSPORT : transport.ToLower(), + string.IsNullOrEmpty(transport) ? DEFAULT_TRANSPORT : transport.ToLower(), out SMBTransportType type) ? type - : throw new UserInformationException($"Transport must be one of: {string.Join(", ", _transportMap.Keys)}","CIFSConfig"); - + : throw new UserInformationException($"Transport must be one of: {string.Join(", ", _transportMap.Keys)}", "CIFSConfig"); + _connectionParameters = new SMBConnectionParameters( uri.Host, transportType, @@ -196,17 +199,12 @@ public class CIFSBackend : IStreamingBackend /// Implementation of interface method for listing remote folder contents /// /// List of IFileEntry with directory listing result - private async Task> ListAsync(CancellationToken cancellationToken) + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancellationToken) { - return await GetConnection().ListAsync(cancellationToken).ConfigureAwait(false); + foreach (var v in await GetConnection().ListAsync(_connectionParameters.Path, cancellationToken).ConfigureAwait(false)) + yield return v; } - /// - /// Wrapper method of legacy non async call to list files in the remote folder - /// - /// - public IEnumerable List() => ListAsync(CancellationToken.None).Await(); - /// /// Upload files to remote location /// @@ -282,7 +280,7 @@ public class CIFSBackend : IStreamingBackend /// /// CancellationToken, in this call not used. /// - public Task GetDNSNamesAsync(CancellationToken cancellationToken) => + public Task GetDNSNamesAsync(CancellationToken cancellationToken) => Task.FromResult(new[] { _DnsName ?? string.Empty }); /// @@ -293,7 +291,7 @@ public class CIFSBackend : IStreamingBackend public async Task TestAsync(CancellationToken cancellationToken) { // This will throw an exception if the folder is missing - await GetConnection().ListAsync(cancellationToken).ConfigureAwait(false); + await GetConnection().ListAsync(_connectionParameters.Path, cancellationToken).ConfigureAwait(false); } /// @@ -306,10 +304,10 @@ public class CIFSBackend : IStreamingBackend { var pathParts = _connectionParameters.Path? .Split(PATH_SEPARATORS, StringSplitOptions.RemoveEmptyEntries); - + if (pathParts == null || pathParts.Length == 0) return; - + await GetConnection().CreateFolderAsync(_connectionParameters.Path, cancellationToken) .ConfigureAwait(false); } @@ -320,6 +318,8 @@ public class CIFSBackend : IStreamingBackend /// An SMB connection that can be used for file operations private SMBShareConnection GetConnection() => _sharedConnection ??= new SMBShareConnection(_connectionParameters); + + /// /// Implementation of Dispose pattern enforced by interface /// @@ -335,4 +335,20 @@ public class CIFSBackend : IStreamingBackend System.Diagnostics.Debug.WriteLine($"Error disposing CIFS connection: {ex.Message}"); } } + + /// + public async IAsyncEnumerable ListAsync(string path, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var sourcePath = _connectionParameters.Path; + if (!string.IsNullOrWhiteSpace(sourcePath)) + sourcePath = Util.AppendDirSeparator(sourcePath, "/"); + + foreach (var v in await GetConnection().ListAsync(sourcePath + BackendSourceFileEntry.NormalizePathTo(path, '/'), cancellationToken).ConfigureAwait(false)) + if (v.Name != "." && v.Name != "..") + yield return v; + } + + /// + public Task GetEntryAsync(string path, CancellationToken cancellationToken) + => Task.FromResult(null); } \ No newline at end of file diff --git a/Duplicati/Library/Backend/CIFS/Duplicati.Library.Backend.CIFS.csproj b/Duplicati/Library/Backend/CIFS/Duplicati.Library.Backend.CIFS.csproj index b638e8235..11375a4ab 100644 --- a/Duplicati/Library/Backend/CIFS/Duplicati.Library.Backend.CIFS.csproj +++ b/Duplicati/Library/Backend/CIFS/Duplicati.Library.Backend.CIFS.csproj @@ -10,6 +10,7 @@ + diff --git a/Duplicati/Library/Backend/CIFS/SMBShareConnection.cs b/Duplicati/Library/Backend/CIFS/SMBShareConnection.cs index 44b936464..47e23b742 100644 --- a/Duplicati/Library/Backend/CIFS/SMBShareConnection.cs +++ b/Duplicati/Library/Backend/CIFS/SMBShareConnection.cs @@ -79,7 +79,7 @@ public class SMBShareConnection : IDisposable, IAsyncDisposable if (!_smb2Client.Connect(connectionParameters.ServerName, connectionParameters.TransportType)) throw new UserInformationException($"{LC.L("Failed to connect to server")} {connectionParameters.ServerName}", "ConnectionError"); - var status = _smb2Client.Login(connectionParameters.AuthDomain, connectionParameters.AuthUser, connectionParameters.AuthPassword); + var status = _smb2Client.Login(connectionParameters.AuthDomain ?? "", connectionParameters.AuthUser ?? "", connectionParameters.AuthPassword ?? ""); if (status != NTStatus.STATUS_SUCCESS) throw new UserInformationException($"{LC.L("Failed to authenticate to server")} {connectionParameters.ServerName} with status {status}", "ConnectionError"); @@ -194,9 +194,10 @@ public class SMBShareConnection : IDisposable, IAsyncDisposable /// /// Lists the folder contents of the share and path specified in the connection parameters. /// + /// Path to list /// Cancellation Token /// Exception to be displayed to user - public async Task> ListAsync(CancellationToken cancellationToken) + public async Task> ListAsync(string path, CancellationToken cancellationToken) { await _semaphore.WaitAsync(cancellationToken); @@ -206,11 +207,10 @@ public class SMBShareConnection : IDisposable, IAsyncDisposable { try { - var status = _smbFileStore.CreateFile( out directoryHandle, out fileStatus, - NormalizeSlashes(_connectionParameters.Path), + NormalizeSlashes(path), AccessMask.GENERIC_READ, FileAttributes.Directory, ShareAccess.Read | ShareAccess.Write, @@ -222,7 +222,7 @@ public class SMBShareConnection : IDisposable, IAsyncDisposable if (status == NTStatus.STATUS_OBJECT_PATH_NOT_FOUND || status == NTStatus.STATUS_OBJECT_NAME_NOT_FOUND) throw new FolderMissingException(); else - throw new UserInformationException($"{LC.L("Failed to open directory")} {NormalizeSlashes(_connectionParameters.Path)} with status {status.ToString()}", "DirectoryOpenError"); + throw new UserInformationException($"{LC.L("Failed to open directory")} {NormalizeSlashes(path)} with status {status}", "DirectoryOpenError"); List fileList; status = _smbFileStore.QueryDirectory( @@ -232,7 +232,7 @@ public class SMBShareConnection : IDisposable, IAsyncDisposable FileInformationClass.FileDirectoryInformation); if (status != NTStatus.STATUS_NO_MORE_FILES) - throw new UserInformationException($"{LC.L("Failed to query directory contents")} with status {status.ToString()}", "DirectoryQueryError"); + throw new UserInformationException($"{LC.L("Failed to query directory contents")} with status {status}", "DirectoryQueryError"); return [ @@ -244,7 +244,8 @@ public class SMBShareConnection : IDisposable, IAsyncDisposable info.LastAccessTime, info.LastWriteTime) { - IsFolder = info.FileAttributes == FileAttributes.Directory + IsFolder = info.FileAttributes == FileAttributes.Directory, + Created = info.CreationTime }) .ToList() ]; diff --git a/Duplicati/Library/Backend/CloudFiles/CloudFiles.cs b/Duplicati/Library/Backend/CloudFiles/CloudFiles.cs index dbcd48dba..22329ead1 100644 --- a/Duplicati/Library/Backend/CloudFiles/CloudFiles.cs +++ b/Duplicati/Library/Backend/CloudFiles/CloudFiles.cs @@ -1,31 +1,31 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; -using Duplicati.Library.Utility; using System; using System.Collections.Generic; using System.Linq; using System.Net; +using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; @@ -133,10 +133,10 @@ namespace Duplicati.Library.Backend get { return "cloudfiles"; } } - public IEnumerable List() + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { - string extraUrl = "?format=xml&limit=" + ITEM_LIST_LIMIT.ToString(); - string markerUrl = ""; + var extraUrl = "?format=xml&limit=" + ITEM_LIST_LIMIT.ToString(); + var markerUrl = ""; bool repeat; @@ -169,14 +169,14 @@ namespace Duplicati.Library.Backend //The response should be 404 from the server, but it is not :( if (lst.Count == 0 && markerUrl == "") //Only on first iteration { - try { CreateFolderAsync(CancellationToken.None).Await(); } + try { await CreateFolderAsync(cancelToken).ConfigureAwait(false); } catch { } //Ignore } - string lastItemName = ""; + var lastItemName = ""; foreach (System.Xml.XmlNode n in lst) { - string name = n["name"].InnerText; + var name = n["name"].InnerText; long size; DateTime mod; @@ -264,12 +264,9 @@ namespace Duplicati.Library.Backend #region IBackend_v2 Members - public Task TestAsync(CancellationToken cancelToken) - { + public Task TestAsync(CancellationToken cancelToken) => //The "Folder not found" is not detectable :( - this.TestList(); - return Task.CompletedTask; - } + this.TestListAsync(cancelToken); public Task CreateFolderAsync(CancellationToken cancelToken) { diff --git a/Duplicati/Library/Backend/Dropbox/Dropbox.cs b/Duplicati/Library/Backend/Dropbox/Dropbox.cs index 1713a730e..e929d9da9 100644 --- a/Duplicati/Library/Backend/Dropbox/Dropbox.cs +++ b/Duplicati/Library/Backend/Dropbox/Dropbox.cs @@ -23,6 +23,7 @@ using Duplicati.Library.Interface; using System; using System.Collections.Generic; using System.IO; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -112,16 +113,31 @@ namespace Duplicati.Library.Backend } } - public IEnumerable List() + private async Task HandleListExceptions(Func> func) { - var lfr = HandleListExceptions(() => dbx.ListFiles(m_path)); + try + { + return await func().ConfigureAwait(false); + } + catch (DropboxException de) + { + if (de.errorJSON["error"][".tag"].ToString() == "path" && de.errorJSON["error"]["path"][".tag"].ToString() == "not_found") + throw new FolderMissingException(); + + throw; + } + } + + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) + { + var lfr = await HandleListExceptions(() => dbx.ListFiles(m_path, cancelToken)).ConfigureAwait(false); foreach (var md in lfr.entries) yield return ParseEntry(md); while (lfr.has_more) { - lfr = HandleListExceptions(() => dbx.ListFilesContinue(lfr.cursor)); + lfr = await HandleListExceptions(() => dbx.ListFilesContinue(lfr.cursor, cancelToken)).ConfigureAwait(false); foreach (var md in lfr.entries) yield return ParseEntry(md); } @@ -168,10 +184,7 @@ namespace Duplicati.Library.Backend public Task GetDNSNamesAsync(CancellationToken cancelToken) => Task.FromResult(WebApi.Dropbox.Hosts()); public Task TestAsync(CancellationToken cancelToken) - { - this.TestList(); - return Task.CompletedTask; - } + => this.TestListAsync(cancelToken); public async Task CreateFolderAsync(CancellationToken cancelToken) { diff --git a/Duplicati/Library/Backend/Dropbox/DropboxHelper.cs b/Duplicati/Library/Backend/Dropbox/DropboxHelper.cs index a8d6e3cf7..cb9d23c2a 100644 --- a/Duplicati/Library/Backend/Dropbox/DropboxHelper.cs +++ b/Duplicati/Library/Backend/Dropbox/DropboxHelper.cs @@ -43,7 +43,7 @@ namespace Duplicati.Library.Backend base.AccessTokenOnly = !accessToken.Contains(":"); } - public ListFolderResult ListFiles(string path) + public async Task ListFiles(string path, CancellationToken cancelToken) { var pa = new PathArg { @@ -52,7 +52,7 @@ namespace Duplicati.Library.Backend try { - return PostAndGetJSONData(WebApi.Dropbox.ListFilesUrl(), pa); + return await PostAndGetJSONDataAsync(WebApi.Dropbox.ListFilesUrl(), cancelToken, pa).ConfigureAwait(false); } catch (Exception ex) { @@ -61,13 +61,13 @@ namespace Duplicati.Library.Backend } } - public ListFolderResult ListFilesContinue(string cursor) + public async Task ListFilesContinue(string cursor, CancellationToken cancelToken) { var lfca = new ListFolderContinueArg() { cursor = cursor }; try { - return PostAndGetJSONData(WebApi.Dropbox.ListFilesContinueUrl(), lfca); + return await PostAndGetJSONDataAsync(WebApi.Dropbox.ListFilesContinueUrl(), cancelToken, lfca).ConfigureAwait(false); } catch (Exception ex) { diff --git a/Duplicati/Library/Backend/FTP/Duplicati.Library.Backend.FTP.csproj b/Duplicati/Library/Backend/FTP/Duplicati.Library.Backend.FTP.csproj index 1a417bc16..3f6aade5c 100644 --- a/Duplicati/Library/Backend/FTP/Duplicati.Library.Backend.FTP.csproj +++ b/Duplicati/Library/Backend/FTP/Duplicati.Library.Backend.FTP.csproj @@ -19,6 +19,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Duplicati/Library/Backend/FTP/FTPBackend.cs b/Duplicati/Library/Backend/FTP/FTPBackend.cs index 41a17fb94..1f5095120 100644 --- a/Duplicati/Library/Backend/FTP/FTPBackend.cs +++ b/Duplicati/Library/Backend/FTP/FTPBackend.cs @@ -28,6 +28,7 @@ using System.IO; using System.Linq; using System.Net; using System.Net.Security; +using System.Runtime.CompilerServices; using System.Security.Authentication; using System.Text; using System.Threading; @@ -324,7 +325,7 @@ namespace Duplicati.Library.Backend var dataConnectionType = CoreUtility.ParseEnumOption(options, CONFIG_KEY_FTP_DATA_CONNECTION_TYPE, DEFAULT_DATA_CONNECTION_TYPE); var encryptionMode = CoreUtility.ParseEnumOption(options, CONFIG_KEY_FTP_ENCRYPTION_MODE, DEFAULT_ENCRYPTION_MODE); - var sslProtocols = CoreUtility.ParseEnumOption(options, CONFIG_KEY_FTP_SSL_PROTOCOLS, DEFAULT_SSL_PROTOCOLS); + var sslProtocols = CoreUtility.ParseFlagsOption(options, CONFIG_KEY_FTP_SSL_PROTOCOLS, DEFAULT_SSL_PROTOCOLS); // Process options of the legacy FTP backend if (ProtocolKey == "ftp") @@ -366,63 +367,14 @@ namespace Duplicati.Library.Backend } /// - public IEnumerable List() + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { + FtpListItem[] items; try { - var list = new List(); var client = CreateClient(CancellationToken.None).Await(); var remotePath = PreparePathForClient(null); - - foreach (var item in client.GetListing(remotePath, FtpListOption.Modify | FtpListOption.Size).Await()) - { - switch (item.Type) - { - case FtpObjectType.Directory: - if (item.Name == "." || item.Name == "..") - continue; - - list.Add(new FileEntry(item.Name, -1, new DateTime(), item.Modified) - { - IsFolder = true, - }); - break; - - case FtpObjectType.File: - list.Add(new FileEntry(item.Name, item.Size, new DateTime(), item.Modified)); - break; - - case FtpObjectType.Link: - { - if (item.Name == "." || item.Name == "..") - continue; - - if (item.LinkObject != null) - { - switch (item.LinkObject.Type) - { - case FtpObjectType.Directory: - if (item.Name == "." || item.Name == "..") - continue; - - list.Add(new FileEntry(item.Name, -1, new DateTime(), item.Modified) - { - IsFolder = true, - }); - break; - - case FtpObjectType.File: - list.Add(new FileEntry(item.Name, item.Size, new DateTime(), item.Modified)); - break; - } - } - break; - } - - } - } - - return list; + items = await client.GetListing(remotePath, FtpListOption.Modify | FtpListOption.Size).ConfigureAwait(false); } catch (Exception e) { @@ -431,6 +383,54 @@ namespace Duplicati.Library.Backend throw; } + + foreach (var item in items) + { + switch (item.Type) + { + case FtpObjectType.Directory: + if (item.Name == "." || item.Name == "..") + continue; + + yield return new FileEntry(item.Name, -1, new DateTime(), item.Modified) + { + IsFolder = true, + }; + break; + + case FtpObjectType.File: + yield return new FileEntry(item.Name, item.Size, new DateTime(), item.Modified); + break; + + case FtpObjectType.Link: + { + if (item.Name == "." || item.Name == "..") + continue; + + if (item.LinkObject != null) + { + switch (item.LinkObject.Type) + { + case FtpObjectType.Directory: + if (item.Name == "." || item.Name == "..") + continue; + + yield return new FileEntry(item.Name, -1, new DateTime(), item.Modified) + { + IsFolder = true, + }; + break; + + case FtpObjectType.File: + yield return new FileEntry(item.Name, item.Size, new DateTime(), item.Modified); + break; + } + } + break; + } + + } + } } /// @@ -552,8 +552,7 @@ namespace Duplicati.Library.Backend // Remove the file if it exists try { - var list = List(); - if (list.Any(entry => entry.Name == TEST_FILE_NAME)) + if (await ListAsync(cancellationToken).AnyAsync(entry => entry.Name == TEST_FILE_NAME).ConfigureAwait(false)) await DeleteAsync(TEST_FILE_NAME, cancellationToken).ConfigureAwait(false); } catch (Exception e) diff --git a/Duplicati/Library/Backend/File/Duplicati.Library.Backend.File.csproj b/Duplicati/Library/Backend/File/Duplicati.Library.Backend.File.csproj index f8a99e4c3..baa6fa128 100644 --- a/Duplicati/Library/Backend/File/Duplicati.Library.Backend.File.csproj +++ b/Duplicati/Library/Backend/File/Duplicati.Library.Backend.File.csproj @@ -11,6 +11,7 @@ + @@ -18,6 +19,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Duplicati/Library/Backend/File/FileBackend.cs b/Duplicati/Library/Backend/File/FileBackend.cs index 89dbb44fc..3441940a6 100644 --- a/Duplicati/Library/Backend/File/FileBackend.cs +++ b/Duplicati/Library/Backend/File/FileBackend.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Common.IO; @@ -24,6 +24,7 @@ using Duplicati.Library.Interface; using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Threading; @@ -172,14 +173,15 @@ namespace Duplicati.Library.Backend get { return "file"; } } - public IEnumerable List() + /// + public IAsyncEnumerable ListAsync(CancellationToken cancelToken) { PreAuthenticate(); if (!systemIO.DirectoryExists(m_path)) throw new FolderMissingException(Strings.FileBackend.FolderMissingError(m_path)); - return systemIO.EnumerateFileEntries(m_path); + return systemIO.EnumerateFileEntries(m_path).ToAsyncEnumerable(); } #if DEBUG_RETRY @@ -281,10 +283,7 @@ namespace Duplicati.Library.Backend } public Task TestAsync(CancellationToken cancelToken) - { - this.TestList(); - return Task.CompletedTask; - } + => this.TestListAsync(cancelToken); public Task CreateFolderAsync(CancellationToken cancelToken) { @@ -452,14 +451,15 @@ namespace Duplicati.Library.Backend long? sourceStreamLength = sourceStream == null ? null : Utility.Utility.GetStreamLength(sourceStream, out isStreamPostion); if (sourceStreamLength.HasValue && targetFileInfo.Length != sourceStreamLength.Value) - throw new FileMissingException($"Target file size ({targetFileInfo.Length:n0}) is different from the source length ({sourceStreamLength.Value:n0}){(isStreamPostion ? " - ending stream position)" : "")}. Target: {targetFilePath}"); + throw new FileMissingException($"Target file size ({targetFileInfo.Length:n0}) is different from the source length ({sourceStreamLength.Value:n0}){(isStreamPostion ? " - ending stream position" : "")}. Target: {targetFilePath}"); if (expectedLength.HasValue && targetFileInfo.Length != expectedLength.Value) throw new FileMissingException($"Target file size ({targetFileInfo.Length:n0}) is different from the expected length ({expectedLength.Value:n0}). Target: {targetFilePath}"); } catch { - try { System.IO.File.Delete(targetFilePath); } catch { } + try { System.IO.File.Delete(targetFilePath); } + catch { } throw; } } diff --git a/Duplicati/Library/Backend/GoogleServices/Duplicati.Library.Backend.GoogleServices.csproj b/Duplicati/Library/Backend/GoogleServices/Duplicati.Library.Backend.GoogleServices.csproj index b9c7aa57c..198df6dd6 100644 --- a/Duplicati/Library/Backend/GoogleServices/Duplicati.Library.Backend.GoogleServices.csproj +++ b/Duplicati/Library/Backend/GoogleServices/Duplicati.Library.Backend.GoogleServices.csproj @@ -7,6 +7,7 @@ + diff --git a/Duplicati/Library/Backend/GoogleServices/GoogleCloudStorage.cs b/Duplicati/Library/Backend/GoogleServices/GoogleCloudStorage.cs index fae9abd03..30e9db6de 100644 --- a/Duplicati/Library/Backend/GoogleServices/GoogleCloudStorage.cs +++ b/Duplicati/Library/Backend/GoogleServices/GoogleCloudStorage.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Backend.GoogleServices; @@ -27,6 +27,7 @@ using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Net; +using System.Runtime.CompilerServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -100,11 +101,11 @@ namespace Duplicati.Library.Backend.GoogleCloudStorage public string storageClass { get; set; } } - private T HandleListExceptions(Func func) + private async Task HandleListExceptions(Func> func) { try { - return func(); + return await func().ConfigureAwait(false); } catch (WebException wex) { @@ -116,12 +117,13 @@ namespace Duplicati.Library.Backend.GoogleCloudStorage } #region IBackend implementation - public IEnumerable List() + /// + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { var url = WebApi.GoogleCloudStorage.ListUrl(m_bucket, Utility.Uri.UrlEncode(m_prefix)); while (true) { - var resp = HandleListExceptions(() => m_oauth.ReadJSONResponse(url)); + var resp = await HandleListExceptions(() => m_oauth.ReadJSONResponseAsync(url, cancelToken)).ConfigureAwait(false); if (resp.items != null) foreach (var f in resp.items) @@ -165,10 +167,7 @@ namespace Duplicati.Library.Backend.GoogleCloudStorage } public Task TestAsync(CancellationToken cancelToken) - { - this.TestList(); - return Task.CompletedTask; - } + => this.TestListAsync(cancelToken); public async Task CreateFolderAsync(CancellationToken cancelToken) { diff --git a/Duplicati/Library/Backend/GoogleServices/GoogleDrive.cs b/Duplicati/Library/Backend/GoogleServices/GoogleDrive.cs index eea2ae4cb..96088c5b7 100644 --- a/Duplicati/Library/Backend/GoogleServices/GoogleDrive.cs +++ b/Duplicati/Library/Backend/GoogleServices/GoogleDrive.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Backend.GoogleServices; @@ -29,6 +29,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; +using System.Runtime.CompilerServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -78,8 +79,7 @@ namespace Duplicati.Library.Backend.GoogleDrive foreach (var p in path.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries)) { - var res = ListFolder(curparent, true, p).ToArray(); - + var res = await ListFolder(curparent, true, p, cancelToken).ToArrayAsync(cancelToken).ConfigureAwait(false); if (res.Length == 0) { if (!autocreate) @@ -119,7 +119,7 @@ namespace Duplicati.Library.Backend.GoogleDrive return entries; var currentFolderId = await GetCurrentFolderIdAsync(cancelToken).ConfigureAwait(false); - entries = ListFolder(currentFolderId, false, remotename).ToArray(); + entries = await ListFolder(currentFolderId, false, remotename, cancelToken).ToArrayAsync(cancelToken).ConfigureAwait(false); if (entries == null || entries.Length == 0) { @@ -145,7 +145,7 @@ namespace Duplicati.Library.Backend.GoogleDrive { // Figure out if we update or create the file if (m_filecache.Count == 0) - foreach (var file in List()) { /* Enumerate the full listing */ } + await foreach (var file in ListAsync(cancelToken).ConfigureAwait(false)) { /* Enumerate the full listing */ } GoogleDriveFolderItem[] files; m_filecache.TryGetValue(remotename, out files); @@ -188,7 +188,7 @@ namespace Duplicati.Library.Backend.GoogleDrive { // Prevent repeated download url lookups if (m_filecache.Count == 0) - foreach (var file in List()) { /* Enumerate the full listing */ } + await foreach (var file in ListAsync(cancelToken).ConfigureAwait(false)) { /* Enumerate the full listing */ } var fileId = (await GetFileEntriesAsync(remotename, true, cancelToken).ConfigureAwait(false)).OrderByDescending(x => x.createdDate).First().id; @@ -203,7 +203,9 @@ namespace Duplicati.Library.Backend.GoogleDrive #region IBackend implementation - public IEnumerable List() + /// + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) + { bool success = false; try @@ -211,8 +213,8 @@ namespace Duplicati.Library.Backend.GoogleDrive m_filecache.Clear(); // For now, this class assumes that List() fully populates the file cache - var currentFolderId = GetCurrentFolderIdAsync(CancellationToken.None).Await(); - foreach (var n in ListFolder(currentFolderId)) + var currentFolderId = await GetCurrentFolderIdAsync(cancelToken).ConfigureAwait(false); + await foreach (var n in ListFolder(currentFolderId, null, null, cancelToken).ConfigureAwait(false)) { FileEntry fe = null; @@ -294,10 +296,7 @@ namespace Duplicati.Library.Backend.GoogleDrive } public Task TestAsync(CancellationToken cancelToken) - { - this.TestList(); - return Task.CompletedTask; - } + => this.TestListAsync(cancelToken); public async Task CreateFolderAsync(CancellationToken cancelToken) { @@ -432,7 +431,7 @@ namespace Duplicati.Library.Backend.GoogleDrive public string rootFolderId { get; set; } } - private IEnumerable ListFolder(string parentfolder, bool? onlyFolders = null, string name = null) + private async IAsyncEnumerable ListFolder(string parentfolder, bool? onlyFolders, string name, [EnumeratorCancellation] CancellationToken cancelToken) { var fileQuery = new string[] { string.IsNullOrEmpty(name) ? null : string.Format("title = '{0}'", EscapeTitleEntries(name)), @@ -446,7 +445,7 @@ namespace Duplicati.Library.Backend.GoogleDrive while (true) { - var res = m_oauth.GetJSONData(url); + var res = await m_oauth.GetJSONDataAsync(url, cancelToken).ConfigureAwait(false); foreach (var n in res.items) yield return n; diff --git a/Duplicati/Library/Backend/Idrivee2/Duplicati.Library.Backend.Idrivee2.csproj b/Duplicati/Library/Backend/Idrivee2/Duplicati.Library.Backend.Idrivee2.csproj index c8664ccd2..756380cd2 100644 --- a/Duplicati/Library/Backend/Idrivee2/Duplicati.Library.Backend.Idrivee2.csproj +++ b/Duplicati/Library/Backend/Idrivee2/Duplicati.Library.Backend.Idrivee2.csproj @@ -8,6 +8,7 @@ + diff --git a/Duplicati/Library/Backend/Idrivee2/Idrivee2Backend.cs b/Duplicati/Library/Backend/Idrivee2/Idrivee2Backend.cs index 59dca09bd..15358a635 100644 --- a/Duplicati/Library/Backend/Idrivee2/Idrivee2Backend.cs +++ b/Duplicati/Library/Backend/Idrivee2/Idrivee2Backend.cs @@ -21,17 +21,17 @@ using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; -using Duplicati.Library.Utility; using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; namespace Duplicati.Library.Backend { - public class Idrivee2Backend : IBackend, IStreamingBackend + public class Idrivee2Backend : IBackend, IStreamingBackend, IFolderEnabledBackend { private static readonly string LOGTAG = Logging.Log.LogTagFromType(); @@ -80,7 +80,6 @@ namespace Duplicati.Library.Backend m_s3Client = new S3AwsClient(accessKeyId, accessKeySecret, null, host, null, true, false, options); - } public string GetRegionEndpoint(string url) @@ -125,17 +124,10 @@ namespace Duplicati.Library.Backend public bool SupportsStreaming => true; - - public IEnumerable List() + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { - foreach (IFileEntry file in Connection.ListBucket(m_bucket, m_prefix)) - { - ((FileEntry)file).Name = file.Name.Substring(m_prefix.Length); - if (file.Name.StartsWith("/", StringComparison.Ordinal) && !m_prefix.StartsWith("/", StringComparison.Ordinal)) - ((FileEntry)file).Name = file.Name.Substring(1); - + await foreach (IFileEntry file in Connection.ListBucketAsync(m_bucket, m_prefix, false, cancelToken).ConfigureAwait(false)) yield return file; - } } public async Task PutAsync(string remotename, string localname, CancellationToken cancelToken) @@ -189,10 +181,7 @@ namespace Duplicati.Library.Backend } public Task TestAsync(CancellationToken cancelToken) - { - this.TestList(); - return Task.CompletedTask; - } + => this.TestListAsync(cancelToken); public Task CreateFolderAsync(CancellationToken cancelToken) { @@ -230,5 +219,20 @@ namespace Duplicati.Library.Backend //AWS SDK encodes the filenames correctly return m_prefix + name; } + + /// + public IAsyncEnumerable ListAsync(string path, CancellationToken cancellationToken) + { + var filterPath = GetFullKey(path); + if (!string.IsNullOrWhiteSpace(filterPath)) + filterPath = Util.AppendDirSeparator(filterPath, "/"); + + return m_s3Client.ListBucketAsync(m_bucket, filterPath, true, cancellationToken); + } + + + /// + public Task GetEntryAsync(string path, CancellationToken cancellationToken) + => Task.FromResult(null); } } diff --git a/Duplicati/Library/Backend/Jottacloud/Jottacloud.cs b/Duplicati/Library/Backend/Jottacloud/Jottacloud.cs index f7bdc164d..9a4c25336 100644 --- a/Duplicati/Library/Backend/Jottacloud/Jottacloud.cs +++ b/Duplicati/Library/Backend/Jottacloud/Jottacloud.cs @@ -25,6 +25,7 @@ using Duplicati.Library.Localization.Short; using System; using System.Collections.Generic; using System.Net; +using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; @@ -182,8 +183,12 @@ namespace Duplicati.Library.Backend get { return "jottacloud"; } } - public IEnumerable List() + /// + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { + // Remove warning until this backend is rewritten with HttpClient + await Task.CompletedTask; + var doc = new System.Xml.XmlDocument(); try { @@ -330,10 +335,7 @@ namespace Duplicati.Library.Backend } public Task TestAsync(CancellationToken cancelToken) - { - this.TestList(); - return Task.CompletedTask; - } + => this.TestListAsync(cancelToken); public Task CreateFolderAsync(CancellationToken cancelToken) { diff --git a/Duplicati/Library/Backend/Mega/MegaBackend.cs b/Duplicati/Library/Backend/Mega/MegaBackend.cs index 0357976ed..573133f0d 100644 --- a/Duplicati/Library/Backend/Mega/MegaBackend.cs +++ b/Duplicati/Library/Backend/Mega/MegaBackend.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using CG.Web.MegaApiClient; @@ -28,7 +28,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using OtpNet; -using Duplicati.Library.Utility; +using System.Runtime.CompilerServices; namespace Duplicati.Library.Backend.Mega { @@ -190,15 +190,17 @@ namespace Duplicati.Library.Backend.Mega #region IBackend implementation - public IEnumerable List() + /// + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { if (m_filecache == null) - ResetFileCacheAsync(null, CancellationToken.None).Await(); + await ResetFileCacheAsync(null, cancelToken).ConfigureAwait(false); - return - from n in m_filecache.Values - let item = n.OrderByDescending(x => x.ModificationDate).First() - select new FileEntry(item.Name, item.Size, item.ModificationDate ?? new DateTime(0), item.ModificationDate ?? new DateTime(0)); + foreach (var n in m_filecache.Values) + { + var item = n.OrderByDescending(x => x.ModificationDate).First(); + yield return new FileEntry(item.Name, item.Size, item.ModificationDate ?? new DateTime(0), item.ModificationDate ?? new DateTime(0)); + } } public async Task PutAsync(string remotename, string filename, CancellationToken cancelToken) @@ -236,10 +238,7 @@ namespace Duplicati.Library.Backend.Mega } public Task TestAsync(CancellationToken cancelToken) - { - this.TestList(); - return Task.CompletedTask; - } + => this.TestListAsync(cancelToken); public Task CreateFolderAsync(CancellationToken cancelToken) { diff --git a/Duplicati/Library/Backend/OneDrive/MicrosoftGraphBackend.cs b/Duplicati/Library/Backend/OneDrive/MicrosoftGraphBackend.cs index f33c8931e..5726522cd 100644 --- a/Duplicati/Library/Backend/OneDrive/MicrosoftGraphBackend.cs +++ b/Duplicati/Library/Backend/OneDrive/MicrosoftGraphBackend.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Backend.MicrosoftGraph; using Duplicati.Library.Common.IO; @@ -31,6 +31,7 @@ using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Runtime.CompilerServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -318,9 +319,11 @@ namespace Duplicati.Library.Backend } } - public IEnumerable List() + /// + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) + { - foreach (DriveItem item in this.Enumerate(string.Format("{0}/root:{1}:/children", this.DrivePrefix, this.RootPath))) + await foreach (var item in this.Enumerate(string.Format("{0}/root:{1}:/children", this.DrivePrefix, this.RootPath), cancelToken).ConfigureAwait(false)) { // Exclude non-files and deleted items (not sure if they show up in this listing, but make sure anyway) if (item.IsFile && !item.IsDeleted) @@ -667,7 +670,7 @@ namespace Duplicati.Library.Backend } } - private IEnumerable Enumerate(string url) + private async IAsyncEnumerable Enumerate(string url, [EnumeratorCancellation] CancellationToken cancelToken) { string nextUrl = url; while (!string.IsNullOrEmpty(nextUrl)) @@ -675,7 +678,7 @@ namespace Duplicati.Library.Backend GraphCollection results; try { - results = this.GetAsync>(nextUrl, CancellationToken.None).Await(); + results = await this.GetAsync>(nextUrl, cancelToken).ConfigureAwait(false); } catch (DriveItemNotFoundException ex) { diff --git a/Duplicati/Library/Backend/OpenStack/OpenStackStorage.cs b/Duplicati/Library/Backend/OpenStack/OpenStackStorage.cs index 2b626e0f6..5731a8a31 100644 --- a/Duplicati/Library/Backend/OpenStack/OpenStackStorage.cs +++ b/Duplicati/Library/Backend/OpenStack/OpenStackStorage.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -32,6 +32,7 @@ using System.Text; using Duplicati.Library.Common.IO; using System.Threading; using System.Threading.Tasks; +using System.Runtime.CompilerServices; namespace Duplicati.Library.Backend.OpenStack { @@ -75,7 +76,9 @@ namespace Duplicati.Library.Backend.OpenStack new KeyValuePair("Infomaniak Swiss Backup cluster 1", "https://swiss-backup.infomaniak.com/identity/v3"), new KeyValuePair("Infomaniak Swiss Backup cluster 2", "https://swiss-backup02.infomaniak.com/identity/v3"), new KeyValuePair("Infomaniak Swiss Backup cluster 3", "https://swiss-backup03.infomaniak.com/identity/v3"), + new KeyValuePair("Infomaniak Swiss Backup cluster 4", "https://swiss-backup04.infomaniak.com/identity/v3"), new KeyValuePair("Infomaniak Public Cloud 1", "https://api.pub1.infomaniak.cloud/identity/v3"), + new KeyValuePair("Infomaniak Public Cloud 2", "https://api.pub2.infomaniak.cloud/identity/v3"), new KeyValuePair("Catalyst Cloud - nz-hlz-1 (NZ)", "https://api.nz-hlz-1.catalystcloud.io:5000/v3"), new KeyValuePair("Catalyst Cloud - nz-por-1 (NZ)", "https://api.nz-por-1.catalystcloud.io:5000/v3"), }; @@ -528,11 +531,11 @@ namespace Duplicati.Library.Backend.OpenStack #endregion #region IBackend implementation - private T HandleListExceptions(Func func) + private async Task HandleListExceptions(Func> func) { try { - return func(); + return await func().ConfigureAwait(false); } catch (WebException wex) { @@ -543,7 +546,8 @@ namespace Duplicati.Library.Backend.OpenStack } } - public IEnumerable List() + /// + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { var plainurl = JoinUrls(SimpleStorageEndPoint, m_container) + string.Format("?format=json&delimiter=/&limit={0}", PAGE_LIMIT); if (!string.IsNullOrEmpty(m_prefix)) @@ -556,7 +560,7 @@ namespace Duplicati.Library.Backend.OpenStack var req = m_helper.CreateRequest(url); req.Accept = "application/json"; - var items = HandleListExceptions(() => m_helper.ReadJSONResponse(req)); + var items = await HandleListExceptions(() => m_helper.ReadJSONResponseAsync(req, cancelToken)).ConfigureAwait(false); foreach (var n in items) { var name = n.name; @@ -596,10 +600,8 @@ namespace Duplicati.Library.Backend.OpenStack return m_helper.ReadJSONResponseAsync(url, cancelToken, null, "DELETE"); } public Task TestAsync(CancellationToken cancelToken) - { - this.TestList(); - return Task.CompletedTask; - } + => this.TestListAsync(cancelToken); + public async Task CreateFolderAsync(CancellationToken cancelToken) { var url = JoinUrls(SimpleStorageEndPoint, m_container); diff --git a/Duplicati/Library/Backend/Rclone/Rclone.cs b/Duplicati/Library/Backend/Rclone/Rclone.cs index 8e790d17e..7bdd2f8c2 100644 --- a/Duplicati/Library/Backend/Rclone/Rclone.cs +++ b/Duplicati/Library/Backend/Rclone/Rclone.cs @@ -1,33 +1,33 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; -using Duplicati.Library.Utility; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Runtime.CompilerServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -192,14 +192,15 @@ namespace Duplicati.Library.Backend } - public IEnumerable List() + /// + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { - String str_result; + string str_result; try { - str_result = RcloneCommandExecuter(rclone_executable, $"lsjson {remote_repo}:{remote_path}", CancellationToken.None).Await(); + str_result = await RcloneCommandExecuter(rclone_executable, $"lsjson {remote_repo}:{remote_path}", cancelToken).ConfigureAwait(false); // this will give an error if the executable does not exist. } @@ -297,10 +298,7 @@ namespace Duplicati.Library.Backend public Task GetDNSNamesAsync(CancellationToken cancelToken) => Task.FromResult(new[] { remote_repo }); public Task TestAsync(CancellationToken cancelToken) - { - this.TestList(); - return Task.CompletedTask; - } + => this.TestListAsync(cancelToken); public Task CreateFolderAsync(CancellationToken cancelToken) { diff --git a/Duplicati/Library/Backend/S3/Duplicati.Library.Backend.S3.csproj b/Duplicati/Library/Backend/S3/Duplicati.Library.Backend.S3.csproj index 90016385f..148804d3b 100644 --- a/Duplicati/Library/Backend/S3/Duplicati.Library.Backend.S3.csproj +++ b/Duplicati/Library/Backend/S3/Duplicati.Library.Backend.S3.csproj @@ -12,10 +12,10 @@ - + diff --git a/Duplicati/Library/Backend/S3/IS3Client.cs b/Duplicati/Library/Backend/S3/IS3Client.cs index 7fd5fd55a..4b029cf9c 100644 --- a/Duplicati/Library/Backend/S3/IS3Client.cs +++ b/Duplicati/Library/Backend/S3/IS3Client.cs @@ -22,24 +22,24 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; namespace Duplicati.Library.Backend { - public interface IS3Client : IDisposable - { - IEnumerable ListBucket(string bucketName, string prefix); + public interface IS3Client : IDisposable + { + IAsyncEnumerable ListBucketAsync(string bucketName, string prefix, bool recursive, CancellationToken cancellationToken); - Task AddBucketAsync(string bucketName, CancellationToken cancelToken); + Task AddBucketAsync(string bucketName, CancellationToken cancelToken); - Task DeleteObjectAsync(string bucketName, string keyName, CancellationToken cancelToken); + Task DeleteObjectAsync(string bucketName, string keyName, CancellationToken cancelToken); - Task RenameFileAsync(string bucketName, string source, string target, CancellationToken cancelToken); + Task RenameFileAsync(string bucketName, string source, string target, CancellationToken cancelToken); - Task GetFileStreamAsync(string bucketName, string keyName, System.IO.Stream target, CancellationToken cancelToken); + Task GetFileStreamAsync(string bucketName, string keyName, System.IO.Stream target, CancellationToken cancelToken); - string GetDnsHost(); + string GetDnsHost(); - Task AddFileStreamAsync(string bucketName, string keyName, System.IO.Stream source, CancellationToken cancelToken); - } + Task AddFileStreamAsync(string bucketName, string keyName, System.IO.Stream source, CancellationToken cancelToken); + } } \ No newline at end of file diff --git a/Duplicati/Library/Backend/S3/S3AwsClient.cs b/Duplicati/Library/Backend/S3/S3AwsClient.cs index 9e9d6ffef..ec404e861 100644 --- a/Duplicati/Library/Backend/S3/S3AwsClient.cs +++ b/Duplicati/Library/Backend/S3/S3AwsClient.cs @@ -28,6 +28,7 @@ using Duplicati.Library.Utility; using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -69,6 +70,7 @@ namespace Duplicati.Library.Backend /// The DNS host of the S3 server /// private readonly string m_dnsHost; + private readonly bool m_useV2ListApi; /// /// The archive classes that are considered archive classes @@ -98,6 +100,7 @@ namespace Duplicati.Library.Backend m_client = new AmazonS3Client(awsID, awsKey, cfg); + m_useV2ListApi = string.Equals(options.GetValueOrDefault("list-api-version", "v1"), "v2", StringComparison.OrdinalIgnoreCase); m_locationConstraint = locationConstraint; m_storageClass = storageClass; m_dnsHost = string.IsNullOrWhiteSpace(cfg.ServiceURL) ? null : new System.Uri(cfg.ServiceURL).Host; @@ -243,32 +246,67 @@ namespace Duplicati.Library.Backend return m_client.DeleteObjectAsync(objectDeleteRequest, cancellationToken); } - public virtual IEnumerable ListBucket(string bucketName, string prefix) + /// + /// Lists the contents of a bucket, using a plugable API call + /// + /// The bucket to list + /// The prefix to list + /// If true, the list is recursive + /// The cancellation token + /// The list of files + public async IAsyncEnumerable ListBucketAsync(string bucketName, string prefix, bool recursive, [EnumeratorCancellation] CancellationToken cancellationToken) { - bool isTruncated = true; + var isTruncated = true; string filename = null; + var delimiter = recursive ? "" : "/"; + if (!string.IsNullOrWhiteSpace(prefix)) + prefix = Util.AppendDirSeparator(prefix, "/"); //TODO: Figure out if this is the case with AWSSDK too //Unfortunately S3 sometimes reports duplicate values when requesting more than one page of results //So, track the files that have already been returned and skip any duplicates. - HashSet alreadyReturned = new HashSet(); + var alreadyReturned = new HashSet(); //We truncate after ITEM_LIST_LIMIT elements, and then repeat while (isTruncated) { - var listRequest = new ListObjectsRequest { BucketName = bucketName }; - - if (!string.IsNullOrEmpty(filename)) - listRequest.Marker = filename; - - listRequest.MaxKeys = ITEM_LIST_LIMIT; - if (!string.IsNullOrEmpty(prefix)) - listRequest.Prefix = prefix; - - ListObjectsResponse listResponse; + ListObjectsV2Response listResponse; try { - listResponse = m_client.ListObjectsAsync(listRequest).Await(); + if (m_useV2ListApi) + { + var listRequest = new ListObjectsV2Request + { + BucketName = bucketName, + Prefix = prefix, + ContinuationToken = filename, + MaxKeys = ITEM_LIST_LIMIT, + Delimiter = delimiter + }; + listResponse = await m_client.ListObjectsV2Async(listRequest, cancellationToken).ConfigureAwait(false); + } + else + { + // Use the V1 API + var listRequest = new ListObjectsRequest + { + BucketName = bucketName, + Prefix = prefix, + Marker = filename, + MaxKeys = ITEM_LIST_LIMIT, + Delimiter = delimiter + }; + var listResponsev1 = await m_client.ListObjectsAsync(listRequest, cancellationToken).ConfigureAwait(false); + + // Map the V1 response to the V2 response + listResponse = new ListObjectsV2Response + { + CommonPrefixes = listResponsev1.CommonPrefixes, + IsTruncated = listResponsev1.IsTruncated, + NextContinuationToken = listResponsev1.NextMarker, + S3Objects = listResponsev1.S3Objects + }; + } } catch (AmazonS3Exception e) { @@ -282,18 +320,40 @@ namespace Duplicati.Library.Backend } isTruncated = listResponse.IsTruncated; - filename = listResponse.NextMarker; + filename = listResponse.NextContinuationToken; + + foreach (var obj in listResponse.CommonPrefixes) + { + if (obj == prefix || !obj.StartsWith(prefix)) + continue; + + // Because the prefixes are returned, and not the folder objects + // we do not get the folder modification date + yield return new FileEntry( + obj.Substring(prefix.Length), + -1, + new DateTime(0), + new DateTime(0) + ) + { IsFolder = true }; + } foreach (var obj in listResponse.S3Objects.Where(obj => alreadyReturned.Add(obj.Key))) { - yield return new Common.IO.FileEntry( - obj.Key, + // Skip self-prefix, this discards the folder modification date :/ + if (obj.Key == prefix || !obj.Key.StartsWith(prefix)) + continue; + + yield return new FileEntry( + obj.Key.Substring(prefix.Length), obj.Size, obj.LastModified, - obj.LastModified, - false, - m_archiveClasses.Contains(obj.StorageClass) - ); + obj.LastModified + ) + { + IsFolder = obj.Key.EndsWith("/"), + IsArchived = m_archiveClasses.Contains(obj.StorageClass) + }; } } } diff --git a/Duplicati/Library/Backend/S3/S3Backend.cs b/Duplicati/Library/Backend/S3/S3Backend.cs index 7e8e11b30..ccf82d6fd 100644 --- a/Duplicati/Library/Backend/S3/S3Backend.cs +++ b/Duplicati/Library/Backend/S3/S3Backend.cs @@ -25,6 +25,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using System.Text.RegularExpressions; using System.Threading; @@ -32,7 +33,7 @@ using System.Threading.Tasks; namespace Duplicati.Library.Backend { - public class S3 : IBackend, IStreamingBackend, IRenameEnabledBackend + public class S3 : IBackend, IStreamingBackend, IRenameEnabledBackend, IFolderEnabledBackend { private static readonly string LOGTAG = Logging.Log.LogTagFromType(); @@ -42,6 +43,8 @@ namespace Duplicati.Library.Backend private const string SSL_OPTION = "use-ssl"; private const string S3_CLIENT_OPTION = "s3-client"; private const string S3_DISABLE_CHUNK_ENCODING_OPTION = "s3-disable-chunk-encoding"; + private const string S3_LIST_API_VERSION_OPTION = "s3-list-api-version"; + private const string S3_RECURSIVE_LIST = "s3-recursive-list"; public static readonly Dictionary KNOWN_S3_PROVIDERS = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "Amazon S3", "s3.amazonaws.com" }, @@ -65,7 +68,9 @@ namespace Duplicati.Library.Backend { "Infomaniak Swiss Backup cluster 1", "s3.swiss-backup.infomaniak.com" }, { "Infomaniak Swiss Backup cluster 2", "s3.swiss-backup02.infomaniak.com" }, { "Infomaniak Swiss Backup cluster 3", "s3.swiss-backup03.infomaniak.com" }, + { "Infomaniak Swiss Backup cluster 4", "s3.swiss-backup04.infomaniak.com" }, { "Infomaniak Public Cloud 1", "s3.pub1.infomaniak.cloud" }, + { "Infomaniak Public Cloud 2", "s3.pub2.infomaniak.cloud" }, { "さくらのクラウド (Sakura Cloud)", "s3.isk01.sakurastorage.jp" }, { "Seagate Lyve - US-East-1", "https://s3.us-east-1.lyvecloud.seagate.com" }, { "Seagate Lyve - US-West-1", "https://s3.us-west-1.lyvecloud.seagate.com" }, @@ -172,6 +177,7 @@ namespace Duplicati.Library.Backend private readonly string m_bucket; private readonly string m_prefix; + private readonly bool m_recurseLists; private const string DEFAULT_S3_HOST = "s3.amazonaws.com"; private IS3Client s3Client; @@ -224,6 +230,8 @@ namespace Duplicati.Library.Backend if (m_prefix.Length != 0) m_prefix = Util.AppendDirSeparator(m_prefix, "/"); + m_recurseLists = Utility.Utility.ParseBoolOption(options, S3_RECURSIVE_LIST); + // Auto-disable DNS lookup for non-AWS configurations if (!options.ContainsKey("s3-ext-forcepathstyle") && !hostname.EndsWith(".amazonaws.com", StringComparison.OrdinalIgnoreCase)) options["s3-ext-forcepathstyle"] = "true"; @@ -268,20 +276,8 @@ namespace Duplicati.Library.Backend get { return true; } } - - public IEnumerable List() - { - foreach (var file in Connection.ListBucket(m_bucket, m_prefix)) - { - file.Name = file.Name.Substring(m_prefix.Length); - - //Fix for a bug in Duplicati 1.0 beta 3 and earlier, where filenames are incorrectly prefixed with a slash - if (file.Name.StartsWith("/", StringComparison.Ordinal) && !m_prefix.StartsWith("/", StringComparison.Ordinal)) - file.Name = file.Name.Substring(1); - - yield return file; - } - } + public IAsyncEnumerable ListAsync(CancellationToken cancelToken) + => Connection.ListBucketAsync(m_bucket, m_prefix, m_recurseLists, cancelToken); public async Task PutAsync(string remotename, string localname, CancellationToken cancelToken) { @@ -334,6 +330,8 @@ namespace Duplicati.Library.Backend new CommandLineArgument(S3_CLIENT_OPTION, CommandLineArgument.ArgumentType.Enumeration, Strings.S3Backend.S3ClientDescriptionShort, Strings.S3Backend.S3ClientDescriptionLong, "aws", null, new string[] { "aws", "minio" }), new CommandLineArgument(S3_DISABLE_CHUNK_ENCODING_OPTION, CommandLineArgument.ArgumentType.Boolean, Strings.S3Backend.DescriptionDisableChunkEncodingShort, Strings.S3Backend.DescriptionDisableChunkEncodingLong, "false"), new CommandLineArgument(S3AwsClient.S3_ARCHIVE_CLASSES_OPTION, CommandLineArgument.ArgumentType.Flags, Strings.S3Backend.S3ArchiveClassesDescriptionShort, Strings.S3Backend.S3ArchiveClassesDescriptionLong, string.Join(",", S3AwsClient.DEFAULT_ARCHIVE_CLASSES.Select(x => x.Value)), null, S3AwsClient.DEFAULT_ARCHIVE_CLASSES.Select(x => x.Value).ToArray()), + new CommandLineArgument(S3_LIST_API_VERSION_OPTION, CommandLineArgument.ArgumentType.Enumeration, Strings.S3Backend.DescriptionListApiVersionShort, Strings.S3Backend.DescriptionListApiVersionLong, "v1", null, ["v1", "v2"]), + new CommandLineArgument(S3_RECURSIVE_LIST, CommandLineArgument.ArgumentType.Boolean, Strings.S3Backend.DescriptionRecursiveListShort, Strings.S3Backend.DescriptionRecursiveListLong, "false"), new CommandLineArgument("auth-password", CommandLineArgument.ArgumentType.Password, Strings.S3Backend.AuthPasswordDescriptionShort, Strings.S3Backend.AuthPasswordDescriptionLong), new CommandLineArgument("auth-username", CommandLineArgument.ArgumentType.String, Strings.S3Backend.AuthUsernameDescriptionShort, Strings.S3Backend.AuthUsernameDescriptionLong), }; @@ -352,10 +350,7 @@ namespace Duplicati.Library.Backend } public Task TestAsync(CancellationToken cancelToken) - { - this.TestList(); - return Task.CompletedTask; - } + => this.TestListAsync(cancelToken); public Task CreateFolderAsync(CancellationToken cancelToken) { @@ -396,5 +391,19 @@ namespace Duplicati.Library.Backend //AWS SDK encodes the filenames correctly return m_prefix + name; } + + /// + public IAsyncEnumerable ListAsync(string path, CancellationToken cancellationToken) + { + var filterPath = GetFullKey(path); + if (!string.IsNullOrWhiteSpace(filterPath)) + filterPath = Util.AppendDirSeparator(filterPath, "/"); + + return s3Client.ListBucketAsync(m_bucket, filterPath, m_recurseLists, cancellationToken); + } + + /// + public Task GetEntryAsync(string path, CancellationToken cancellationToken) + => Task.FromResult(null); } } diff --git a/Duplicati/Library/Backend/S3/S3MinioClient.cs b/Duplicati/Library/Backend/S3/S3MinioClient.cs index 04f5285ff..3f7d48f1c 100644 --- a/Duplicati/Library/Backend/S3/S3MinioClient.cs +++ b/Duplicati/Library/Backend/S3/S3MinioClient.cs @@ -22,7 +22,9 @@ using System; using System.Collections.Generic; using System.IO; using System.Reactive.Linq; +using System.Runtime.CompilerServices; using System.Threading; +using System.Threading.Channels; using System.Threading.Tasks; using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; @@ -59,21 +61,47 @@ namespace Duplicati.Library.Backend m_dnsHost = servername; } - public IEnumerable ListBucket(string bucketName, string prefix) + private static async IAsyncEnumerable ToAsyncEnumerable( + IObservable observable, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var channel = Channel.CreateUnbounded(); // Buffered channel for async iteration + + using var subscription = observable.Subscribe( + item => channel.Writer.TryWrite(item), + ex => channel.Writer.TryComplete(ex), + () => channel.Writer.TryComplete() + ); + + while (await channel.Reader.WaitToReadAsync(cancellationToken)) + { + while (channel.Reader.TryRead(out var item)) + { + yield return item; + } + } + } + + public async IAsyncEnumerable ListBucketAsync(string bucketName, string prefix, bool recursive, [EnumeratorCancellation] CancellationToken cancellationToken) { ThrowExceptionIfBucketDoesNotExist(bucketName); - var observable = m_client.ListObjectsAsync(bucketName, prefix, true); + if (!string.IsNullOrWhiteSpace(prefix)) + prefix = Util.AppendDirSeparator(prefix, "/"); - foreach (var obj in observable.ToEnumerable()) + var observable = m_client.ListObjectsAsync(bucketName, prefix, recursive, cancellationToken); + await foreach (var obj in ToAsyncEnumerable(observable, cancellationToken).ConfigureAwait(false)) { + if (obj.Key == prefix || !obj.Key.StartsWith(prefix)) + continue; + yield return new FileEntry( - obj.Key, + obj.Key.Substring(prefix.Length), (long)obj.Size, Convert.ToDateTime(obj.LastModified), - Convert.ToDateTime(obj.LastModified), - obj.IsDir - ); + Convert.ToDateTime(obj.LastModified) + ) + { IsFolder = obj.Key.EndsWith("/") }; } } diff --git a/Duplicati/Library/Backend/S3/Strings.cs b/Duplicati/Library/Backend/S3/Strings.cs index d2814e856..4482930be 100644 --- a/Duplicati/Library/Backend/S3/Strings.cs +++ b/Duplicati/Library/Backend/S3/Strings.cs @@ -21,36 +21,40 @@ using Duplicati.Library.Localization.Short; namespace Duplicati.Library.Backend.Strings { - internal static class S3Backend - { - public static string Description_v2 { get { return LC.L(@"This backend can read and write data to an S3 compatible server. Allowed format is ""s3://bucketname/prefix""."); } } - public static string DisplayName { get { return LC.L(@"S3 compatible"); } } - public static string AMZKeyDescriptionLong { get { return LC.L(@"AWS Secret Access Key can be obtained after logging into your AWS account. This can also be supplied through the option --{0}.", "auth-password"); } } - public static string AMZKeyDescriptionShort { get { return LC.L(@"AWS Secret Access Key"); } } - public static string AMZUserIDDescriptionLong { get { return LC.L(@"AWS Access Key ID can be obtained after logging into your AWS account. This can also be supplied through the option --{0}.", "auth-username"); } } - public static string AMZUserIDDescriptionShort { get { return LC.L(@"AWS Access Key ID"); } } - public static string AuthPasswordDescriptionLong { get { return LC.L(@"The password used to connect to the server. This may also be supplied as the environment variable ""AUTH_PASSWORD""."); } } - public static string AuthPasswordDescriptionShort { get { return LC.L(@"Supply the password used to connect to the server"); } } - public static string AuthUsernameDescriptionLong { get { return LC.L(@"The username used to connect to the server. This may also be supplied as the environment variable ""AUTH_USERNAME""."); } } - public static string AuthUsernameDescriptionShort { get { return LC.L(@"Supply the username used to connect to the server"); } } - public static string NoAMZKeyError { get { return LC.L(@"No S3 secret key given"); } } - public static string NoAMZUserIDError { get { return LC.L(@"No S3 userID given"); } } - public static string S3LocationDescriptionLong(string regions) { return LC.L(@"This option is only used when creating new buckets. Use this option to change what region the data is stored in. Amazon charges slightly more for non-US buckets. Known bucket locations: + internal static class S3Backend + { + public static string Description_v2 { get { return LC.L(@"This backend can read and write data to an S3 compatible server. Allowed format is ""s3://bucketname/prefix""."); } } + public static string DisplayName { get { return LC.L(@"S3 compatible"); } } + public static string AMZKeyDescriptionLong { get { return LC.L(@"AWS Secret Access Key can be obtained after logging into your AWS account. This can also be supplied through the option --{0}.", "auth-password"); } } + public static string AMZKeyDescriptionShort { get { return LC.L(@"AWS Secret Access Key"); } } + public static string AMZUserIDDescriptionLong { get { return LC.L(@"AWS Access Key ID can be obtained after logging into your AWS account. This can also be supplied through the option --{0}.", "auth-username"); } } + public static string AMZUserIDDescriptionShort { get { return LC.L(@"AWS Access Key ID"); } } + public static string AuthPasswordDescriptionLong { get { return LC.L(@"The password used to connect to the server. This may also be supplied as the environment variable ""AUTH_PASSWORD""."); } } + public static string AuthPasswordDescriptionShort { get { return LC.L(@"Supply the password used to connect to the server"); } } + public static string AuthUsernameDescriptionLong { get { return LC.L(@"The username used to connect to the server. This may also be supplied as the environment variable ""AUTH_USERNAME""."); } } + public static string AuthUsernameDescriptionShort { get { return LC.L(@"Supply the username used to connect to the server"); } } + public static string NoAMZKeyError { get { return LC.L(@"No S3 secret key given"); } } + public static string NoAMZUserIDError { get { return LC.L(@"No S3 userID given"); } } + public static string S3LocationDescriptionLong(string regions) { return LC.L(@"This option is only used when creating new buckets. Use this option to change what region the data is stored in. Amazon charges slightly more for non-US buckets. Known bucket locations: {0}", regions); } - public static string S3LocationDescriptionShort { get { return LC.L(@"Specify S3 location constraints"); } } - public static string S3ServerNameDescriptionLong(string providers) { return LC.L(@"Companies other than Amazon are now supporting the S3 API, meaning that this backend can read and write data to those providers as well. Use this option to set the hostname. Currently known providers are: + public static string S3LocationDescriptionShort { get { return LC.L(@"Specify S3 location constraints"); } } + public static string S3ServerNameDescriptionLong(string providers) { return LC.L(@"Companies other than Amazon are now supporting the S3 API, meaning that this backend can read and write data to those providers as well. Use this option to set the hostname. Currently known providers are: {0}", providers); } - public static string S3ServerNameDescriptionShort { get { return LC.L(@"Specify an alternate S3 server name"); } } - public static string S3ClientDescriptionLong { get { return LC.L(@"Set either to aws or minio. Then either the AWS SDK or Minio SDK will be used to communicate with S3 services."); } } - public static string S3ClientDescriptionShort { get { return LC.L(@"Specify the S3 client library to use"); } } - public static string DescriptionUseSSLLong { get { return LC.L(@"Use this option to communicate using Secure Socket Layer (SSL) over http (https). Note that bucket names containing a period has problems with SSL connections."); } } - public static string DescriptionUseSSLShort { get { return LC.L(@"Instruct Duplicati to use an SSL (https) connection"); } } - public static string DescriptionDisableChunkEncodingLong { get { return LC.L(@"This disables chunk encoding for the aws client, which is not supported by all S3 providers."); } } - public static string DescriptionDisableChunkEncodingShort { get { return LC.L(@"Disable chunk encoding (aws client only)"); } } - public static string S3StorageclassDescriptionLong { get { return LC.L(@"Use this option to specify a storage class. If this option is not used, the server will choose a default storage class."); } } - public static string S3StorageclassDescriptionShort { get { return LC.L(@"Specify storage class"); } } - public static string S3ArchiveClassesDescriptionShort { get { return LC.L(@"Specify archive storage class"); } } - public static string S3ArchiveClassesDescriptionLong { get { return LC.L(@"Use this option to specify what storage classes are considered archive storage classes. With this option it is possible to allow lifecycle policies to move data to cheaper storage classes and prevent Duplicati from accessing archived data. This option is only supported for the AWS client."); } } - public static string UnknownS3ClientError(string client) { return LC.L(@"Unknown S3 client: {0}", client); } - } + public static string S3ServerNameDescriptionShort { get { return LC.L(@"Specify an alternate S3 server name"); } } + public static string S3ClientDescriptionLong { get { return LC.L(@"Set either to aws or minio. Then either the AWS SDK or Minio SDK will be used to communicate with S3 services."); } } + public static string S3ClientDescriptionShort { get { return LC.L(@"Specify the S3 client library to use"); } } + public static string DescriptionUseSSLLong { get { return LC.L(@"Use this option to communicate using Secure Socket Layer (SSL) over http (https). Note that bucket names containing a period has problems with SSL connections."); } } + public static string DescriptionUseSSLShort { get { return LC.L(@"Instruct Duplicati to use an SSL (https) connection"); } } + public static string DescriptionDisableChunkEncodingLong { get { return LC.L(@"This disables chunk encoding for the aws client, which is not supported by all S3 providers."); } } + public static string DescriptionDisableChunkEncodingShort { get { return LC.L(@"Disable chunk encoding (aws client only)"); } } + public static string S3StorageclassDescriptionLong { get { return LC.L(@"Use this option to specify a storage class. If this option is not used, the server will choose a default storage class."); } } + public static string S3StorageclassDescriptionShort { get { return LC.L(@"Specify storage class"); } } + public static string S3ArchiveClassesDescriptionShort { get { return LC.L(@"Specify archive storage class"); } } + public static string S3ArchiveClassesDescriptionLong { get { return LC.L(@"Use this option to specify what storage classes are considered archive storage classes. With this option it is possible to allow lifecycle policies to move data to cheaper storage classes and prevent Duplicati from accessing archived data. This option is only supported for the AWS client."); } } + public static string DescriptionListApiVersionShort { get { return LC.L(@"Specify the S3 list API version to use"); } } + public static string DescriptionListApiVersionLong { get { return LC.L(@"Use this option to specify the S3 list API version to use. This can be used to work around issues with some S3 providers."); } } + public static string DescriptionRecursiveListShort { get { return LC.L(@"Use this option to list all files in the bucket"); } } + public static string DescriptionRecursiveListLong { get { return LC.L(@"To reduce the number of objects listed, the default is to only list the first level of objects. Use this option to list all objects in the bucket."); } } + public static string UnknownS3ClientError(string client) { return LC.L(@"Unknown S3 client: {0}", client); } + } } diff --git a/Duplicati/Library/Backend/SSHv2/Duplicati.Library.Backend.SSHv2.csproj b/Duplicati/Library/Backend/SSHv2/Duplicati.Library.Backend.SSHv2.csproj index 9182306df..7c7bbd948 100644 --- a/Duplicati/Library/Backend/SSHv2/Duplicati.Library.Backend.SSHv2.csproj +++ b/Duplicati/Library/Backend/SSHv2/Duplicati.Library.Backend.SSHv2.csproj @@ -12,6 +12,7 @@ + diff --git a/Duplicati/Library/Backend/SSHv2/SSHv2Backend.cs b/Duplicati/Library/Backend/SSHv2/SSHv2Backend.cs index 073217e2b..518273565 100644 --- a/Duplicati/Library/Backend/SSHv2/SSHv2Backend.cs +++ b/Duplicati/Library/Backend/SSHv2/SSHv2Backend.cs @@ -1,43 +1,46 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; +using Duplicati.Library.SourceProvider; using Renci.SshNet; using Renci.SshNet.Common; using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; namespace Duplicati.Library.Backend { - public class SSHv2 : IStreamingBackend, IRenameEnabledBackend + public class SSHv2 : IStreamingBackend, IRenameEnabledBackend, IFolderEnabledBackend { private const string SSH_KEYFILE_OPTION = "ssh-keyfile"; private const string SSH_KEYFILE_INLINE = "ssh-key"; private const string SSH_FINGERPRINT_OPTION = "ssh-fingerprint"; + private const string SSH_RELATIVE_PATH_OPTION = "ssh-relative-path"; private const string SSH_FINGERPRINT_ACCEPT_ANY_OPTION = "ssh-accept-any-fingerprints"; public const string KEYFILE_URI = "sshkey://"; private const string SSH_TIMEOUT_OPTION = "ssh-operation-timeout"; @@ -55,6 +58,8 @@ namespace Duplicati.Library.Backend private readonly TimeSpan m_keepaliveinterval; private readonly int m_port = 22; + private readonly bool m_useRelativePath; + private string m_initialDirectory; private SftpClient m_con; @@ -81,6 +86,7 @@ namespace Duplicati.Library.Backend m_password = uri.Password; m_fingerprintallowall = Utility.Utility.ParseBoolOption(options, SSH_FINGERPRINT_ACCEPT_ANY_OPTION); + m_useRelativePath = Utility.Utility.ParseBoolOption(options, SSH_RELATIVE_PATH_OPTION); m_path = uri.Path; @@ -89,8 +95,15 @@ namespace Duplicati.Library.Backend m_path = Util.AppendDirSeparator(m_path, "/"); } - if (!m_path.StartsWith("/", StringComparison.Ordinal)) - m_path = "/" + m_path; + if (m_useRelativePath) + { + m_path = m_path.TrimStart('/'); + } + else + { + if (!m_path.StartsWith("/", StringComparison.Ordinal)) + m_path = "/" + m_path; + } m_server = uri.Host; @@ -112,22 +125,19 @@ namespace Duplicati.Library.Backend #region IBackend Members public Task TestAsync(CancellationToken cancelToken) - { - this.TestList(); - return Task.CompletedTask; - } + => this.TestListAsync(cancelToken); - public Task CreateFolderAsync(CancellationToken cancelToken) + public async Task CreateFolderAsync(CancellationToken cancelToken) { - CreateConnection(); + await CreateConnection(cancelToken).ConfigureAwait(false); // Since the SftpClient.CreateDirectory method does not create all the parent directories // as needed, this has to be done manually. - string partialPath = String.Empty; + var partialPath = string.Empty; foreach (string part in m_path.Split('/').Where(x => !String.IsNullOrEmpty(x))) { partialPath += $"/{part}"; - if (this.m_con.Exists(partialPath)) + if (await this.m_con.ExistsAsync(partialPath, cancelToken)) { if (!this.m_con.GetAttributes(partialPath).IsDirectory) { @@ -136,11 +146,10 @@ namespace Duplicati.Library.Backend } else { - this.m_con.CreateDirectory(partialPath); + await this.m_con.CreateDirectoryAsync(partialPath, cancelToken).ConfigureAwait(false); } } - return Task.CompletedTask; } public string DisplayName => Strings.SSHv2Backend.DisplayName; @@ -165,8 +174,8 @@ namespace Duplicati.Library.Backend { try { - CreateConnection(); - ChangeDirectory(m_path); + await CreateConnection(cancelToken).ConfigureAwait(false); + await SetWorkingDirectory(cancelToken).ConfigureAwait(false); await m_con.DeleteFileAsync(remotename, cancelToken).ConfigureAwait(false); } catch (SftpPathNotFoundException ex) @@ -205,6 +214,9 @@ namespace Duplicati.Library.Backend new CommandLineArgument(SSH_KEEPALIVE_OPTION, CommandLineArgument.ArgumentType.Timespan, Strings.SSHv2Backend.DescriptionSshkeepaliveShort, Strings.SSHv2Backend.DescriptionSshkeepaliveLong, "0"), + new CommandLineArgument(SSH_RELATIVE_PATH_OPTION, CommandLineArgument.ArgumentType.Boolean, + Strings.SSHv2Backend.DescriptionRelativePathShort, + Strings.SSHv2Backend.DescriptionRelativePathLong) }); } } @@ -239,11 +251,11 @@ namespace Duplicati.Library.Backend #region IStreamingBackend Implementation - public Task PutAsync(string remotename, System.IO.Stream stream, CancellationToken cancelToken) + public async Task PutAsync(string remotename, System.IO.Stream stream, CancellationToken cancelToken) { - CreateConnection(); - ChangeDirectory(m_path); - return Task.Factory.FromAsync( + await CreateConnection(cancelToken).ConfigureAwait(false); + await SetWorkingDirectory(cancelToken).ConfigureAwait(false); + await Task.Factory.FromAsync( (cb, state) => m_con.BeginUploadFile(stream, remotename, cb, state, _ => cancelToken.ThrowIfCancellationRequested()), m_con.EndUploadFile, null); @@ -251,8 +263,8 @@ namespace Duplicati.Library.Backend public async Task GetAsync(string remotename, System.IO.Stream stream, CancellationToken cancelToken) { - CreateConnection(); - ChangeDirectory(m_path); + await CreateConnection(cancelToken).ConfigureAwait(false); + await SetWorkingDirectory(cancelToken).ConfigureAwait(false); try { @@ -271,25 +283,25 @@ namespace Duplicati.Library.Backend #region IRenameEnabledBackend Implementation - public Task RenameAsync(string source, string target, CancellationToken cancelToken) + public async Task RenameAsync(string source, string target, CancellationToken cancelToken) { - CreateConnection(); - ChangeDirectory(m_path); - return m_con.RenameFileAsync(source, target, cancelToken); + await CreateConnection(cancelToken).ConfigureAwait(false); + await SetWorkingDirectory(cancelToken).ConfigureAwait(false); + await m_con.RenameFileAsync(source, target, cancelToken).ConfigureAwait(false); } #endregion #region Implementation - private void CreateConnection() + private async Task CreateConnection(CancellationToken cancelToken) { if (m_con != null && m_con.IsConnected) return; if (m_con != null && !m_con.IsConnected) { - this.TryConnect(m_con); + await this.TryConnect(m_con, cancelToken).ConfigureAwait(false); return; } @@ -344,44 +356,50 @@ namespace Duplicati.Library.Backend if (m_keepaliveinterval.Ticks != 0) con.KeepAliveInterval = m_keepaliveinterval; - this.TryConnect(con); + await this.TryConnect(con, cancelToken).ConfigureAwait(false); + if (m_useRelativePath && (string.IsNullOrWhiteSpace(con.WorkingDirectory) || !con.WorkingDirectory.StartsWith("/"))) + throw new UserInformationException("Server does not report absolute initial directory, please switch to absolute paths", "RelativePathNotSupported"); + m_initialDirectory = con.WorkingDirectory; m_con = con; } - private void TryConnect(SftpClient client) - { - client.Connect(); - } + private Task TryConnect(SftpClient client, CancellationToken cancelToken) + => client.ConnectAsync(cancelToken); - private void ChangeDirectory(string path) + private async Task SetWorkingDirectory(CancellationToken cancelToken) { - if (string.IsNullOrEmpty(path)) + if (string.IsNullOrEmpty(m_path)) return; + var targetPath = m_useRelativePath + ? Util.AppendDirSeparator(m_initialDirectory, "/") + m_path + : m_path; + var workingDir = Util.AppendDirSeparator(m_con.WorkingDirectory, "/"); - if (workingDir == path) + if (workingDir == targetPath) return; try { - m_con.ChangeDirectory(path); + await m_con.ChangeDirectoryAsync(targetPath, cancelToken).ConfigureAwait(false); } catch (Exception ex) { throw new Interface.FolderMissingException( - Strings.SSHv2Backend.FolderNotFoundManagedError(path, ex.Message), ex); + Strings.SSHv2Backend.FolderNotFoundManagedError(m_path, ex.Message), ex); } } - public IEnumerable List() + /// + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { - string path = "."; + var path = "."; - CreateConnection(); - ChangeDirectory(m_path); + await CreateConnection(cancelToken).ConfigureAwait(false); + await SetWorkingDirectory(cancelToken).ConfigureAwait(false); - foreach (var ls in m_con.ListDirectory(path)) + await foreach (var ls in m_con.ListDirectoryAsync(path, cancelToken).ConfigureAwait(false)) { if (ls.Name.ToString() == "." || ls.Name.ToString() == "..") continue; yield return new FileEntry(ls.Name, ls.Length, @@ -396,7 +414,7 @@ namespace Duplicati.Library.Backend { if (!filename.StartsWith(KEYFILE_URI, StringComparison.OrdinalIgnoreCase)) { - return String.IsNullOrEmpty(password) + return string.IsNullOrEmpty(password) ? new PrivateKeyFile(filename) : new PrivateKeyFile(filename, password); } @@ -409,7 +427,7 @@ namespace Duplicati.Library.Backend ms.Position = 0; - return String.IsNullOrEmpty(password) ? new PrivateKeyFile(ms) : new PrivateKeyFile(ms, password); + return string.IsNullOrEmpty(password) ? new PrivateKeyFile(ms) : new PrivateKeyFile(ms, password); } } catch (Exception ex) @@ -429,5 +447,37 @@ namespace Duplicati.Library.Backend } public Task GetDNSNamesAsync(CancellationToken cancelToken) => Task.FromResult(new[] { m_server }); + + /// + public async IAsyncEnumerable ListAsync(string path, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await CreateConnection(cancellationToken).ConfigureAwait(false); + await SetWorkingDirectory(cancellationToken).ConfigureAwait(false); + + var prefixPath = m_useRelativePath ? "" : m_path; + if (!string.IsNullOrWhiteSpace(prefixPath)) + prefixPath = Util.AppendDirSeparator(prefixPath, "/"); + + var filterPath = prefixPath + BackendSourceFileEntry.NormalizePathTo(path, '/'); + if (!string.IsNullOrWhiteSpace(filterPath)) + filterPath = Util.AppendDirSeparator(filterPath, "/"); + + if (string.IsNullOrWhiteSpace(filterPath)) + filterPath = "."; + + await foreach (var x in Client.ListDirectoryAsync(filterPath, cancellationToken).ConfigureAwait(false)) + yield return new FileEntry( + x.Name, + x.Attributes.Size, + x.Attributes.LastAccessTimeUtc, + x.Attributes.LastWriteTimeUtc) + { + IsFolder = x.Attributes.IsDirectory + }; + } + + /// + public Task GetEntryAsync(string path, CancellationToken cancellationToken) + => Task.FromResult(null); } } \ No newline at end of file diff --git a/Duplicati/Library/Backend/SSHv2/Strings.cs b/Duplicati/Library/Backend/SSHv2/Strings.cs index 95360cdd8..a182b771a 100644 --- a/Duplicati/Library/Backend/SSHv2/Strings.cs +++ b/Duplicati/Library/Backend/SSHv2/Strings.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Localization.Short; namespace Duplicati.Library.Backend.Strings @@ -61,6 +61,8 @@ namespace Duplicati.Library.Backend.Strings public static string DescriptionSshtimeoutShort { get { return LC.L(@"Set the operation timeout value"); } } public static string DescriptionSshkeepaliveLong { get { return LC.L(@"Use this option to enable the keep-alive interval for the SSH connection. If the connection is idle, aggressive firewalls might close the connection. Using keep-alive will keep the connection open in this scenario. If this value is set to zero, the keep-alive is disabled."); } } public static string DescriptionSshkeepaliveShort { get { return LC.L(@"Set a keepalive value"); } } + public static string DescriptionRelativePathShort { get { return LC.L(@"Treat source path as relative to the initial path"); } } + public static string DescriptionRelativePathLong { get { return LC.L(@"Use this option to treat the source path as relative to the initial path. This is useful when the full path of the system is not known."); } } public static string FolderNotFoundManagedError(string foldername, string message) { return LC.L(@"Unable to set folder to {0}, error message: {1}", foldername, message); } public static string FingerprintNotMatchManagedError(string fingerprint) { return LC.L(@"Validation of server fingerprint failed. Server returned fingerprint ""{0}"". Cause of this message is either not correct configuration or Man-in-the-middle attack!", fingerprint); } public static string FingerprintNotSpecifiedManagedError(string fingerprint, string hostkeyoption, string allkeysoptions) { return LC.L(@"Please add --{1}=""{0}"" to trust this host. Optionally you can use --{2} (NOT SECURE) for testing!", fingerprint, hostkeyoption, allkeysoptions); } diff --git a/Duplicati/Library/Backend/SharePoint/SharePointBackend.cs b/Duplicati/Library/Backend/SharePoint/SharePointBackend.cs index 388ce0df9..a5fde38ee 100644 --- a/Duplicati/Library/Backend/SharePoint/SharePointBackend.cs +++ b/Duplicati/Library/Backend/SharePoint/SharePointBackend.cs @@ -28,6 +28,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -473,8 +474,11 @@ namespace Duplicati.Library.Backend await testContextForWebAsync(ctx, true, cancelToken).ConfigureAwait(false); } - public IEnumerable List() { return doListAsync(false, CancellationToken.None).Await(); } - private async Task> doListAsync(bool useNewContext, CancellationToken cancelToken) + /// + public IAsyncEnumerable ListAsync(CancellationToken cancelToken) + => doListAsync(false, cancelToken); + + private async IAsyncEnumerable doListAsync(bool useNewContext, [EnumeratorCancellation] CancellationToken cancelToken) { var ctx = await getSpClientContextAsync(useNewContext, cancelToken).ConfigureAwait(false); SP.Folder remoteFolder = null; @@ -502,15 +506,19 @@ namespace Duplicati.Library.Backend if (retry) { // An exception was caught, and List() should be retried. - return await doListAsync(true, cancelToken).ConfigureAwait(false); + await foreach (var f in doListAsync(true, cancelToken).ConfigureAwait(false)) + yield return f; } else { - return remoteFolder.Folders.Where(ff => ff.Exists) + + var list = remoteFolder.Folders.Where(ff => ff.Exists) .Select(f => new FileEntry(f.Name, -1, f.TimeLastModified, f.TimeLastModified, true, false)) .Concat(remoteFolder.Files.Where(ff => ff.Exists) - .Select(f => new FileEntry(f.Name, f.Length, f.TimeLastModified, f.TimeLastModified, false, false))) - .ToArray(); + .Select(f => new FileEntry(f.Name, f.Length, f.TimeLastModified, f.TimeLastModified, false, false))); + + foreach (var f in list) + yield return f; } } diff --git a/Duplicati/Library/Backend/Sia/Sia.cs b/Duplicati/Library/Backend/Sia/Sia.cs index 3c13bc6ce..e99b91c1a 100644 --- a/Duplicati/Library/Backend/Sia/Sia.cs +++ b/Duplicati/Library/Backend/Sia/Sia.cs @@ -26,6 +26,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Net; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -161,19 +162,22 @@ namespace Duplicati.Library.Backend.Sia public SiaDownloadFile[] Files { get; set; } } - private SiaFileList GetFiles() + private async Task GetFiles(CancellationToken cancelToken) { + // Remove warning until this is rewritten to use HttpClient + await Task.CompletedTask; + var fl = new SiaFileList(); - string endpoint = "/renter/files"; + var endpoint = "/renter/files"; try { - System.Net.HttpWebRequest req = CreateRequest(endpoint); + var req = CreateRequest(endpoint); req.Method = System.Net.WebRequestMethods.Http.Get; - Utility.AsyncHttpRequest areq = new Utility.AsyncHttpRequest(req); + var areq = new Utility.AsyncHttpRequest(req); - using (System.Net.HttpWebResponse resp = (System.Net.HttpWebResponse)areq.GetResponse()) + using (var resp = (System.Net.HttpWebResponse)areq.GetResponse()) { int code = (int)resp.StatusCode; if (code < 200 || code >= 300) @@ -196,9 +200,9 @@ namespace Duplicati.Library.Backend.Sia return fl; } - private bool IsUploadComplete(string siafilename) + private async Task IsUploadComplete(string siafilename, CancellationToken cancelToken) { - SiaFileList fl = GetFiles(); + var fl = await GetFiles(cancelToken).ConfigureAwait(false); if (fl.Files == null) return false; @@ -286,10 +290,7 @@ namespace Duplicati.Library.Backend.Sia #region IBackend Members public Task TestAsync(CancellationToken cancelToken) - { - this.TestList(); - return Task.CompletedTask; - } + => this.TestListAsync(cancelToken); public Task CreateFolderAsync(CancellationToken cancelToken) { @@ -307,12 +308,13 @@ namespace Duplicati.Library.Backend.Sia get { return "sia"; } } - public IEnumerable List() + /// + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { SiaFileList fl; try { - fl = GetFiles(); + fl = await GetFiles(cancelToken).ConfigureAwait(false); } catch (System.Net.WebException wex) { @@ -338,10 +340,10 @@ namespace Duplicati.Library.Backend.Sia } } - public Task PutAsync(string remotename, string filename, CancellationToken cancelToken) + public async Task PutAsync(string remotename, string filename, CancellationToken cancelToken) { - string endpoint = ""; - string siafile = m_targetpath + "/" + remotename; + var endpoint = ""; + var siafile = m_targetpath + "/" + remotename; try { @@ -351,29 +353,25 @@ namespace Duplicati.Library.Backend.Sia Utility.Uri.UrlEncode(filename).Replace("+", "%20") ); - HttpWebRequest req = CreateRequest(endpoint); + var req = CreateRequest(endpoint); req.Method = WebRequestMethods.Http.Post; - AsyncHttpRequest areq = new AsyncHttpRequest(req); + var areq = new AsyncHttpRequest(req); - using (HttpWebResponse resp = (HttpWebResponse)areq.GetResponse()) + using (var resp = (HttpWebResponse)areq.GetResponse()) { int code = (int)resp.StatusCode; if (code < 200 || code >= 300) throw new WebException(resp.StatusDescription, null, WebExceptionStatus.ProtocolError, resp); - while (!IsUploadComplete(siafile)) - { - Thread.Sleep(5000); - } + while (!await IsUploadComplete(siafile, cancelToken).ConfigureAwait(false)) + await Task.Delay(5000, cancelToken).ConfigureAwait(false); } } catch (WebException wex) { throw new Exception(getResponseBodyOnError(endpoint, wex)); } - - return Task.FromResult(true); } public async Task GetAsync(string remotename, string localname, CancellationToken cancelToken) diff --git a/Duplicati/Library/Backend/Storj/StorjBackend.cs b/Duplicati/Library/Backend/Storj/StorjBackend.cs index a626d3869..5de882aea 100644 --- a/Duplicati/Library/Backend/Storj/StorjBackend.cs +++ b/Duplicati/Library/Backend/Storj/StorjBackend.cs @@ -19,10 +19,10 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Interface; -using Duplicati.Library.Utility; using System; using System.Collections.Generic; using System.IO; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using uplink.NET.Interfaces; @@ -238,29 +238,22 @@ namespace Duplicati.Library.Backend.Storj await download.StartDownloadAsync().ConfigureAwait(false); } - public IEnumerable List() + /// + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { - return ListAsync().Await(); - } - - private async Task> ListAsync() - { - List files = new List(); var bucket = await _bucketService.EnsureBucketAsync(_bucket).ConfigureAwait(false); var prefix = GetBasePath(); var objects = await _objectService.ListObjectsAsync(bucket, new ListObjectsOptions { Recursive = true, System = true, Custom = true, Prefix = prefix }).ConfigureAwait(false); foreach (var obj in objects.Items) { - StorjFile file = new StorjFile(obj); + var file = new StorjFile(obj); if (prefix != "") { file.Name = file.Name.Replace(prefix, ""); } - files.Add(file); + yield return file; } - - return files; } public async Task PutAsync(string remotename, string filename, CancellationToken cancelToken) diff --git a/Duplicati/Library/Backend/Storj/StorjFile.cs b/Duplicati/Library/Backend/Storj/StorjFile.cs index 243578e12..5e850049c 100644 --- a/Duplicati/Library/Backend/Storj/StorjFile.cs +++ b/Duplicati/Library/Backend/Storj/StorjFile.cs @@ -33,6 +33,7 @@ namespace Duplicati.Library.Backend.Storj public DateTime LastAccess { get; set; } public DateTime LastModification { get; set; } + public DateTime Created { get; set; } public string Name { get; set; } diff --git a/Duplicati/Library/Backend/TahoeLAFS/TahoeBackend.cs b/Duplicati/Library/Backend/TahoeLAFS/TahoeBackend.cs index fc31f1d3b..22d693924 100644 --- a/Duplicati/Library/Backend/TahoeLAFS/TahoeBackend.cs +++ b/Duplicati/Library/Backend/TahoeLAFS/TahoeBackend.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Common.IO; @@ -26,6 +26,7 @@ using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; using System.Net; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -124,10 +125,7 @@ namespace Duplicati.Library.Backend #region IBackend Members public Task TestAsync(CancellationToken cancelToken) - { - this.TestList(); - return Task.CompletedTask; - } + => this.TestListAsync(cancelToken); public Task CreateFolderAsync(CancellationToken cancelToken) { @@ -150,8 +148,12 @@ namespace Duplicati.Library.Backend get { return "tahoe"; } } - public IEnumerable List() + /// + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { + // Remove warning until this is rewritten to use HttpClient + await Task.CompletedTask; + TahoeEl data; try @@ -160,9 +162,9 @@ namespace Duplicati.Library.Backend req.Method = System.Net.WebRequestMethods.Http.Get; var areq = new Utility.AsyncHttpRequest(req); - using (System.Net.HttpWebResponse resp = (System.Net.HttpWebResponse)areq.GetResponse()) + using (var resp = (System.Net.HttpWebResponse)areq.GetResponse()) { - int code = (int)resp.StatusCode; + var code = (int)resp.StatusCode; if (code < 200 || code >= 300) //For some reason Mono does not throw this automatically throw new System.Net.WebException(resp.StatusDescription, null, System.Net.WebExceptionStatus.ProtocolError, resp); diff --git a/Duplicati/Library/Backend/TencentCOS/COSBackend.cs b/Duplicati/Library/Backend/TencentCOS/COSBackend.cs index af2688017..66f2071e7 100644 --- a/Duplicati/Library/Backend/TencentCOS/COSBackend.cs +++ b/Duplicati/Library/Backend/TencentCOS/COSBackend.cs @@ -26,11 +26,11 @@ using COSXML.Model.Tag; using COSXML.Utils; using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; -using Duplicati.Library.Utility; using Newtonsoft.Json; using System; using System.Collections.Generic; using System.IO; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -178,7 +178,8 @@ namespace Duplicati.Library.Backend.TencentCOS return new CosXmlServer(config, cosCredentialProvider); } - public IEnumerable List() + /// + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { bool isTruncated = true; string filename = null; @@ -186,26 +187,24 @@ namespace Duplicati.Library.Backend.TencentCOS while (isTruncated) { cosXml = GetCosXml(); - string bucket = _cosOptions.Bucket; - string prefix = _cosOptions.Path; - - GetBucketRequest request = new GetBucketRequest(bucket); + var bucket = _cosOptions.Bucket; + var prefix = _cosOptions.Path; + var request = new GetBucketRequest(bucket); request.SetSign(TimeUtils.GetCurrentTime(TimeUnit.SECONDS), 600); if (!string.IsNullOrEmpty(filename)) - { request.SetMarker(filename); - } if (!string.IsNullOrEmpty(prefix)) - { request.SetPrefix(prefix); - } - GetBucketResult result = cosXml.GetBucket(request); + var tcs = new TaskCompletionSource(); + cancelToken.Register(() => tcs.TrySetCanceled()); + cosXml.GetBucket(request, (result) => tcs.SetResult(result as GetBucketResult), (clientEx, serverEx) => tcs.SetException((Exception)clientEx ?? serverEx)); + var result = await tcs.Task.ConfigureAwait(false); - ListBucket info = result.listBucket; + var info = result.listBucket; isTruncated = result.listBucket.isTruncated; filename = result.listBucket.nextMarker; diff --git a/Duplicati/Library/Backend/WEBDAV/WEBDAV.cs b/Duplicati/Library/Backend/WEBDAV/WEBDAV.cs index b9c70a055..8cd59c891 100644 --- a/Duplicati/Library/Backend/WEBDAV/WEBDAV.cs +++ b/Duplicati/Library/Backend/WEBDAV/WEBDAV.cs @@ -27,6 +27,7 @@ using System.Collections.Generic; using System.IO; using System.Net; using System.Net.Http; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -75,7 +76,7 @@ namespace Duplicati.Library.Backend /// It is used to detect a problem with IIS where a file is listed, /// but IIS responds 404 because the file mapping is incorrect. /// - private List m_filenamelist = null; + private HashSet m_filenamelist = null; // According to the WEBDAV standard, the "allprop" request should return all properties, however this seems to fail on some servers (box.net). // I've found this description: http://www.webdav.org/specs/rfc2518.html#METHOD_PROPFIND @@ -173,11 +174,24 @@ namespace Duplicati.Library.Backend public string ProtocolKey => "webdav"; /// - public IEnumerable List() + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { + using var timeoutToken = new CancellationTokenSource(); + timeoutToken.CancelAfter(TimeSpan.FromSeconds(LIST_OPERATION_TIMEOUT_SECONDS)); + + using var requestResources = CreateRequest(string.Empty, new HttpMethod("PROPFIND")); + requestResources.RequestMessage.Headers.Add("Depth", "1"); + requestResources.RequestMessage.Content = new StreamContent(new MemoryStream(PROPFIND_BODY)); + requestResources.RequestMessage.Content.Headers.ContentLength = PROPFIND_BODY.Length; + + var doc = new System.Xml.XmlDocument(); + try { - return ListWithoutExceptionCatch(); + using var response = await requestResources.HttpClient.SendAsync(requestResources.RequestMessage, HttpCompletionOption.ResponseContentRead, timeoutToken.Token).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + using var stream = await response.Content.ReadAsStreamAsync(cancelToken).ConfigureAwait(false); + doc.Load(stream); } catch (HttpRequestException wex) { @@ -189,32 +203,11 @@ namespace Duplicati.Library.Backend throw; } - } - private IEnumerable ListWithoutExceptionCatch() - { - using var timeoutToken = new CancellationTokenSource(); - timeoutToken.CancelAfter(TimeSpan.FromSeconds(LIST_OPERATION_TIMEOUT_SECONDS)); - - using var requestResources = CreateRequest(string.Empty, new HttpMethod("PROPFIND")); - requestResources.RequestMessage.Headers.Add("Depth", "1"); - requestResources.RequestMessage.Content = new StreamContent(new MemoryStream(PROPFIND_BODY)); - requestResources.RequestMessage.Content.Headers.ContentLength = PROPFIND_BODY.Length; - - using var response = requestResources.HttpClient.SendAsync(requestResources.RequestMessage, HttpCompletionOption.ResponseContentRead, timeoutToken.Token).ConfigureAwait(false).GetAwaiter().GetResult(); - - response.EnsureSuccessStatusCode(); // This replaces the if needed when Mono was used. - - var doc = new System.Xml.XmlDocument(); - - doc.Load(response.Content.ReadAsStream()); - - System.Xml.XmlNamespaceManager nm = new System.Xml.XmlNamespaceManager(doc.NameTable); + var nm = new System.Xml.XmlNamespaceManager(doc.NameTable); nm.AddNamespace("D", "DAV:"); - List files = new List(); - m_filenamelist = new List(); - + var filenamelist = new HashSet(); foreach (System.Xml.XmlNode n in doc.SelectNodes("D:multistatus/D:response/D:href", nm)) { //IIS uses %20 for spaces and %2B for + @@ -274,13 +267,16 @@ namespace Duplicati.Library.Backend isCollection = stat.SelectSingleNode("D:resourcetype/D:collection", nm) != null; } - FileEntry fe = new FileEntry(name, size, lastAccess, lastModified); - fe.IsFolder = isCollection; - files.Add(fe); - m_filenamelist.Add(name); + var fe = new FileEntry(name, size, lastAccess, lastModified) + { + IsFolder = isCollection + }; + + filenamelist.Add(name); + yield return fe; } - return files; + m_filenamelist = filenamelist; } /// @@ -311,7 +307,7 @@ namespace Duplicati.Library.Backend var response = await requestResources.HttpClient.SendAsync(requestResources.RequestMessage, combinedTokens.Token).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); // This replaces the if needed when Mono was used. + response.EnsureSuccessStatusCode(); } catch (HttpRequestException wex) @@ -347,10 +343,7 @@ namespace Duplicati.Library.Backend /// public Task TestAsync(CancellationToken cancelToken) - { - List(); - return Task.CompletedTask; - } + => this.TestListAsync(cancelToken); /// public async Task CreateFolderAsync(CancellationToken cancelToken) @@ -363,7 +356,7 @@ namespace Duplicati.Library.Backend using var response = await requestResources.HttpClient.SendAsync(requestResources.RequestMessage, combinedTokens.Token).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); // This replaces the if needed when Mono was used. + response.EnsureSuccessStatusCode(); } #endregion @@ -409,7 +402,7 @@ namespace Duplicati.Library.Backend var request = new HttpRequestMessage(HttpMethod.Get, $"{m_url}{Utility.Uri.UrlEncode(remotename).Replace("+", "%20")}"); request.Headers.Add(HttpRequestHeader.UserAgent.ToString(), "Duplicati WEBDAV Client v" + System.Reflection.Assembly.GetExecutingAssembly().GetName().Version); - + request.Headers.ConnectionClose = !m_useIntegratedAuthentication; // ConnectionClose is incompatible with integrated authentication if (method != null) @@ -435,7 +428,7 @@ namespace Duplicati.Library.Backend using var response = await requestResources.HttpClient.SendAsync(requestResources.RequestMessage, HttpCompletionOption.ResponseHeadersRead, cancelToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); // This replaces the if needed when Mono was used. + response.EnsureSuccessStatusCode(); } catch (HttpRequestException wex) { diff --git a/Duplicati/Library/Backend/pCloud/Model/pCloudFileEntry.cs b/Duplicati/Library/Backend/pCloud/Model/pCloudFileEntry.cs index 78dc53cf8..4799292f6 100644 --- a/Duplicati/Library/Backend/pCloud/Model/pCloudFileEntry.cs +++ b/Duplicati/Library/Backend/pCloud/Model/pCloudFileEntry.cs @@ -44,6 +44,11 @@ internal class pCloudFileEntry : IFileEntry /// public DateTime LastModification { get; set; } + /// + /// Creation of the file + /// + public DateTime Created { get; set; } + /// /// Filename or folder name /// diff --git a/Duplicati/Library/Backend/pCloud/pCloudBackend.cs b/Duplicati/Library/Backend/pCloud/pCloudBackend.cs index f60ace0c9..a7a0ee75f 100644 --- a/Duplicati/Library/Backend/pCloud/pCloudBackend.cs +++ b/Duplicati/Library/Backend/pCloud/pCloudBackend.cs @@ -33,6 +33,7 @@ using System.Threading.Tasks; using Duplicati.Library.Backend.pCloud; using Duplicati.Library.Utility; using Uri = System.Uri; +using System.Runtime.CompilerServices; namespace Duplicati.Library.Backend; @@ -222,19 +223,13 @@ public class pCloudBackend : IStreamingBackend /// Implementation of interface method for listing remote folder contents /// /// List of IFileEntry with directory listing result - public async Task> ListAsync(CancellationToken cancellationToken) + public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancellationToken) { _CachedFolderID ??= await GetFolderId(cancellationToken).ConfigureAwait(false); - - return await List(_CachedFolderID.Value, cancellationToken).ConfigureAwait(false); + foreach (var v in await List(_CachedFolderID.Value, cancellationToken).ConfigureAwait(false)) + yield return v; } - /// - /// Wrapper method of legacy non async call to list files in the remote folder - /// - /// - public IEnumerable List() => ListAsync(CancellationToken.None).Await(); - /// /// Upload files to remote location /// diff --git a/Duplicati/Library/Common/IO/FileEntry.cs b/Duplicati/Library/Common/IO/FileEntry.cs index 9811e7b35..2993042dc 100644 --- a/Duplicati/Library/Common/IO/FileEntry.cs +++ b/Duplicati/Library/Common/IO/FileEntry.cs @@ -44,6 +44,11 @@ namespace Duplicati.Library.Common.IO /// public DateTime LastModification { get; set; } = new DateTime(); + /// + /// Gets or sets the time the file or folder was created + /// + public DateTime Created { get; set; } = new DateTime(); + /// /// Gets or sets the size of the file or folder /// diff --git a/Duplicati/Library/Common/IO/ISystemIO.cs b/Duplicati/Library/Common/IO/ISystemIO.cs index 62479855d..c291d2200 100644 --- a/Duplicati/Library/Common/IO/ISystemIO.cs +++ b/Duplicati/Library/Common/IO/ISystemIO.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -82,6 +82,16 @@ namespace Duplicati.Library.Common.IO void SetMetadata(string path, Dictionary metdata, bool restorePermissions); Dictionary GetMetadata(string path, bool isSymlink, bool followSymlink); + /// + /// Sets the permission to read-write only for the current user. + /// + /// The file to set permissions on. + void FileSetPermissionUserRWOnly(string path); + /// + /// Sets the permission to read-write only for the current user. + /// + /// The directory to set permissions on. + void DirectorySetPermissionUserRWOnly(string path); } } diff --git a/Duplicati/Library/Common/IO/SystemIO.cs b/Duplicati/Library/Common/IO/SystemIO.cs index 8b3bcc027..268940aba 100644 --- a/Duplicati/Library/Common/IO/SystemIO.cs +++ b/Duplicati/Library/Common/IO/SystemIO.cs @@ -1,61 +1,44 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; -using System.Runtime.Versioning; namespace Duplicati.Library.Common.IO { public static class SystemIO { - - /// - /// A cached lookup for windows methods for dealing with long filenames - /// - [SupportedOSPlatform("windows")] - public static readonly ISystemIO IO_WIN; - - [SupportedOSPlatform("linux")] - [SupportedOSPlatform("macOS")] - public static readonly ISystemIO IO_SYS; - public static readonly ISystemIO IO_OS; static SystemIO() { - // TODO: These interfaces cannot be properly guarded by the supported platform attribute in this form. - // They are used in static methods of USNJournal on all platforms. - - // Since that is the case, the warnings will be suppressed with pragma. - -#pragma warning disable CA1416 - IO_WIN = new SystemIOWindows(); - IO_SYS = new SystemIOLinux(); -#pragma warning restore CA1416 if (OperatingSystem.IsWindows()) { - IO_OS = IO_WIN; + IO_OS = new SystemIOWindows(); } else if (OperatingSystem.IsMacOS() || OperatingSystem.IsLinux()) { - IO_OS = IO_SYS; + IO_OS = new SystemIOLinux(); + } + else + { + throw new PlatformNotSupportedException("The current platform is not supported"); } } } diff --git a/Duplicati/Library/Common/IO/SystemIOLinux.cs b/Duplicati/Library/Common/IO/SystemIOLinux.cs index ef43cd719..fea6e8845 100644 --- a/Duplicati/Library/Common/IO/SystemIOLinux.cs +++ b/Duplicati/Library/Common/IO/SystemIOLinux.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -25,6 +25,7 @@ using System.Linq; using System.Collections.Generic; using Duplicati.Library.Interface; using System.Runtime.Versioning; +using System.Runtime.InteropServices; namespace Duplicati.Library.Common.IO { @@ -32,6 +33,26 @@ namespace Duplicati.Library.Common.IO [SupportedOSPlatform("macOS")] public struct SystemIOLinux : ISystemIO { + /// + /// PInvoke methods + /// + private static class PInvoke + { + /// + /// Gets the user ID of the current user + /// + /// The user ID + [DllImport("libc")] + public static extern uint getuid(); + + /// + /// Gets the group ID of the current user + /// + /// + [DllImport("libc")] + public static extern uint getgid(); + } + #region ISystemIO implementation public void DirectoryCreate(string path) @@ -64,7 +85,8 @@ namespace Duplicati.Library.Common.IO File.SetLastWriteTimeUtc(path, time); var gtime = FileGetLastWriteTimeUtc(path); - if(gtime != time) { + if (gtime != time) + { Console.Error.WriteLine($"DISS: {path} {gtime.ToFileTimeUtc() - time.ToFileTimeUtc()}"); } } @@ -214,7 +236,7 @@ namespace Duplicati.Library.Common.IO var n = PosixFile.GetExtendedAttributes(f, isSymlink, followSymlink); if (n != null) - foreach(var x in n) + foreach (var x in n) dict["unix-ext:" + x.Key] = Convert.ToBase64String(x.Value); var fse = PosixFile.GetUserGroupAndPermissions(f); @@ -238,7 +260,7 @@ namespace Duplicati.Library.Common.IO var f = NormalizePath(file); - foreach(var x in data.Where(x => x.Key.StartsWith("unix-ext:", StringComparison.Ordinal)).Select(x => new KeyValuePair(x.Key.Substring("unix-ext:".Length), Convert.FromBase64String(x.Value)))) + foreach (var x in data.Where(x => x.Key.StartsWith("unix-ext:", StringComparison.Ordinal)).Select(x => new KeyValuePair(x.Key.Substring("unix-ext:".Length), Convert.FromBase64String(x.Value)))) PosixFile.SetExtendedAttribute(f, x.Key, x.Value); if (restorePermissions && data.ContainsKey("unix:uid-gid-perm")) @@ -346,6 +368,40 @@ namespace Duplicati.Library.Common.IO { return new FileEntry(fileInfo.Name, fileInfo.Length, fileInfo.LastAccessTime, fileInfo.LastWriteTime); } + + private static (long uid, long gid) GetOwnerAndGroup(string path) + { + var fi = PosixFile.GetUserGroupAndPermissions(path); + return (fi.UID, fi.GID); + } + + /// + /// Sets the unix permission user read-write Only. + /// + /// The file to set permissions on. + public void FileSetPermissionUserRWOnly(string path) + { + PosixFile.SetUserGroupAndPermissions( + path, + PInvoke.getuid(), + PInvoke.getgid(), + Convert.ToInt32("600", 8) /* FilePermissions.S_IRUSR | FilePermissions.S_IWUSR*/ + ); + } + + /// + /// Sets the permission to read-write only for the current user. + /// + /// The directory to set permissions on. + public void DirectorySetPermissionUserRWOnly(string path) + { + PosixFile.SetUserGroupAndPermissions( + path, + PInvoke.getuid(), + PInvoke.getgid(), + Convert.ToInt32("700", 8) /* FilePermissions.S_IRUSR | FilePermissions.S_IWUSR | FilePermissions.S_IXUSR */ + ); + } } } diff --git a/Duplicati/Library/Common/IO/SystemIOWindows.cs b/Duplicati/Library/Common/IO/SystemIOWindows.cs index 32d80495b..72bafb0af 100644 --- a/Duplicati/Library/Common/IO/SystemIOWindows.cs +++ b/Duplicati/Library/Common/IO/SystemIOWindows.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -28,9 +28,11 @@ using System.Linq; using Duplicati.Library.Interface; using Newtonsoft.Json; using System.Runtime.Versioning; +using System.Security.Principal; namespace Duplicati.Library.Common.IO { + [SupportedOSPlatform("windows")] public struct SystemIOWindows : ISystemIO { // Based on the constant names used in @@ -42,6 +44,16 @@ namespace Duplicati.Library.Common.IO private static readonly string DIRSEP = Util.DirectorySeparatorString; + /// + /// The current user name + /// + private static readonly string CURRENT_USERNAME = WindowsIdentity.GetCurrent().Name; + + /// + /// The LocalSystem user name + /// + private static readonly string LOCAL_SYSTEM_NAME = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null).Translate(typeof(NTAccount)).Value; + /// /// Prefix path with one of the extended device path prefixes /// (@"\\?\" or @"\\?\UNC\") but only if it's a fully qualified @@ -671,6 +683,68 @@ namespace Duplicati.Library.Common.IO if ((attr & System.IO.FileAttributes.ReparsePoint) == 0) throw new System.IO.IOException(string.Format("Unable to create symlink, check account permissions: {0}", symlinkfile)); } + + /// + /// Sets the unix permission user read-write Only. + /// + /// The file to set permissions on. + public void FileSetPermissionUserRWOnly(string path) + { + // Create directory security settings + var security = new FileSecurity(); + + // Remove inherited permissions to ensure only the current user has access + security.SetAccessRuleProtection(true, false); + + // Grant the current user read access + security.AddAccessRule(new FileSystemAccessRule( + CURRENT_USERNAME, + FileSystemRights.FullControl, + AccessControlType.Allow + )); + + security.AddAccessRule(new FileSystemAccessRule( + LOCAL_SYSTEM_NAME, + FileSystemRights.FullControl, + AccessControlType.Allow + )); + + // Adjust with the new security settings + new FileInfo(path).SetAccessControl(security); + } + + /// + /// Sets the permission to read-write only for the current user. + /// + /// The directory to set permissions on. + public void DirectorySetPermissionUserRWOnly(string path) + { + // Create directory security settings + var security = new DirectorySecurity(); + + // Remove inherited permissions to ensure only the current user has access + security.SetAccessRuleProtection(true, false); + + // Grant the current user read access + security.AddAccessRule(new FileSystemAccessRule( + CURRENT_USERNAME, + FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, // Apply to subfolders & files + PropagationFlags.None, // Keeps inheritance settings intact + AccessControlType.Allow + )); + + security.AddAccessRule(new FileSystemAccessRule( + LOCAL_SYSTEM_NAME, + FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, // Apply to subfolders & files + PropagationFlags.None, // Keeps inheritance settings intact + AccessControlType.Allow + )); + + // Adjust with the new security settings + new DirectoryInfo(path).SetAccessControl(security); + } #endregion } } diff --git a/Duplicati/Library/Common/IO/Util.cs b/Duplicati/Library/Common/IO/Util.cs index 0d0f8ee75..80b247cf6 100644 --- a/Duplicati/Library/Common/IO/Util.cs +++ b/Duplicati/Library/Common/IO/Util.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -31,8 +31,16 @@ namespace Duplicati.Library.Common.IO /// public static readonly string DirectorySeparatorString = Path.DirectorySeparatorChar.ToString(); + /// + /// A cached instance of the alternate directory separator as a string + /// public static readonly string AltDirectorySeparatorString = Path.AltDirectorySeparatorChar.ToString(); + /// + /// Filename of a marker file that can be put inside the data folder to prevent Duplicati from fixing lax permissions + /// + public const string InsecurePermissionsMarkerFile = "insecure-permissions.txt"; + /// /// Appends the appropriate directory separator to paths, depending on OS. /// Does not append the separator if the path already ends with it. @@ -66,5 +74,18 @@ namespace Duplicati.Library.Common.IO { return string.IsNullOrWhiteSpace(path) || path.StartsWith("/", StringComparison.Ordinal) ? "/" : "\\"; } + + /// + /// Checks if the path is inside the Windows folder + /// + /// The path to check + /// true if the path is inside the Windows folder, false otherwise + public static bool IsPathUnderWindowsFolder(string path) + { + if (!OperatingSystem.IsWindows()) + return false; + + return path.StartsWith(AppendDirSeparator(Environment.GetFolderPath(Environment.SpecialFolder.Windows)), StringComparison.OrdinalIgnoreCase); + } } } diff --git a/Duplicati/Library/Compression/ZipCompression/FileArchiveZip.cs b/Duplicati/Library/Compression/ZipCompression/FileArchiveZip.cs index c59967dc3..5cb89a96c 100644 --- a/Duplicati/Library/Compression/ZipCompression/FileArchiveZip.cs +++ b/Duplicati/Library/Compression/ZipCompression/FileArchiveZip.cs @@ -103,6 +103,16 @@ namespace Duplicati.Library.Compression.ZipCompression /// private readonly IZipArchive m_archive; + /// + /// The fallback archive to use, if any + /// + private IZipArchive? m_fallbackArchive = null; + + /// + /// The parameters used to create the archive + /// + private readonly (Stream Stream, ParsedZipOptions Options, CompressionLibrary Library) m_creationParams; + /// /// Default constructor, used to read file extension and supported commands /// @@ -142,6 +152,7 @@ namespace Duplicati.Library.Compression.ZipCompression var unittestMode = Utility.Utility.ParseBoolOption(options.AsReadOnly(), "unittest-mode"); var parsedOptions = new ParsedZipOptions(compressionLevel, compressionType, usingZip64, unittestMode); + var userCompressionLibrary = compressionLibrary; if (compressionLibrary == CompressionLibrary.Auto) { if (compressionType != CompressionType.Deflate || usingZip64) @@ -165,6 +176,7 @@ namespace Duplicati.Library.Compression.ZipCompression } m_archive = archive ?? new SharpCompressZipArchive(stream, mode, parsedOptions); + m_creationParams = (stream, parsedOptions, userCompressionLibrary); } #region IFileArchive Members @@ -219,7 +231,9 @@ namespace Duplicati.Library.Compression.ZipCompression public void Dispose() { - m_archive.Dispose(); + if (m_fallbackArchive == null) + m_archive.Dispose(); + m_fallbackArchive?.Dispose(); } public string[] ListFiles(string prefix) @@ -229,7 +243,38 @@ namespace Duplicati.Library.Compression.ZipCompression => m_archive.ListFilesWithSize(prefix); public Stream? OpenRead(string file) - => m_archive.OpenRead(file); + { + // If we have started the fallback archive, we should continue using it + if (m_fallbackArchive != null) + return m_fallbackArchive.OpenRead(file); + + try + { + return m_archive.OpenRead(file); + } + catch (Exception ex) + { + if (m_creationParams.Library == CompressionLibrary.Auto && m_archive is not SharpCompressZipArchive && m_creationParams.Stream.CanSeek) + { + Log.WriteWarningMessage(LOGTAG, "CompressionReadErrorFallback", ex, "Failed to open file with built-in ZIP archive, falling back to SharpCompress"); + + try + { + m_creationParams.Item1.Seek(0, SeekOrigin.Begin); + m_archive.Dispose(); + } + catch + { + + } + + m_fallbackArchive = new SharpCompressZipArchive(m_creationParams.Stream, ArchiveMode.Read, m_creationParams.Options); + return m_fallbackArchive.OpenRead(file); + } + + throw; + } + } public DateTime GetLastWriteTime(string file) => m_archive.GetLastWriteTime(file); diff --git a/Duplicati/Library/DynamicLoader/Duplicati.Library.DynamicLoader.csproj b/Duplicati/Library/DynamicLoader/Duplicati.Library.DynamicLoader.csproj index 512e0f909..0ed1b1e42 100644 --- a/Duplicati/Library/DynamicLoader/Duplicati.Library.DynamicLoader.csproj +++ b/Duplicati/Library/DynamicLoader/Duplicati.Library.DynamicLoader.csproj @@ -17,6 +17,7 @@ + diff --git a/Duplicati/Library/DynamicLoader/SourceProviderLoader.cs b/Duplicati/Library/DynamicLoader/SourceProviderLoader.cs new file mode 100644 index 000000000..979721398 --- /dev/null +++ b/Duplicati/Library/DynamicLoader/SourceProviderLoader.cs @@ -0,0 +1,219 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using Duplicati.Library.Interface; +using System.Linq; +using System.Runtime.ExceptionServices; +using Duplicati.Library.SourceProviders; +using Duplicati.Library.SourceProvider; +using System.Threading.Tasks; +using System.Threading; + +namespace Duplicati.Library.DynamicLoader +{ + /// + /// Loads all SourceProviders dynamically and exposes a list of those + /// + public class SourceProviderLoader + { + /// + /// Implementation overrides specific to SourceProviders + /// + private class SourceProviderLoaderSub : DynamicLoader + { + /// + /// Returns the protocol key + /// + /// The item to load the key for + /// The protocol key + protected override string GetInterfaceKey(ISourceProviderModule item) + { + return item.Key; + } + + /// + /// Returns the subfolders searched for SourceProviders + /// + protected override string[] Subfolders => ["SourceProviders"]; + + /// + /// The built-in modules + /// + protected override IEnumerable BuiltInModules => SourceProviderModules.BuiltInSourceProviderModules; + + /// + /// Instanciates a specific SourceProvider, given the url and options + /// + /// The url to create the instance for + /// The options to pass to the instance constructor + /// The instanciated SourceProvider or null if the url is not supported + public ISourceProviderModule GetSourceProvider(string url, string mountPoint, Dictionary options) + { + var uri = new Utility.Uri(url); + + LoadInterfaces(); + + var newOpts = new Dictionary(options); + foreach (var key in uri.QueryParameters.AllKeys) + newOpts[key] = uri.QueryParameters[key]; + + lock (m_lock) + { + try + { + if (m_interfaces.ContainsKey(uri.Scheme)) + return (ISourceProviderModule)Activator.CreateInstance(m_interfaces[uri.Scheme].GetType(), url, mountPoint, newOpts); + } + catch (System.Reflection.TargetInvocationException tex) + { + if (tex.InnerException != null) + { + // Unwrap exceptions for nicer display. The ExceptionDispatchInfo class allows us to + // rethrow an exception without changing the stack trace. + ExceptionDispatchInfo.Capture(tex.InnerException).Throw(); + } + + throw; + } + + return null; + } + } + + /// + /// Gets the supported commands for a certain url + /// + /// The url to find commands for + /// The supported commands or null if the url scheme was not supported + public IReadOnlyList GetSupportedCommands(string url) + { + var uri = new Utility.Uri(url); + + LoadInterfaces(); + + // TODO: The loading logic is replicated in the "GetSourceProvider" method, should be refactored + lock (m_lock) + { + ISourceProviderModule b; + if (m_interfaces.TryGetValue(uri.Scheme, out b) && b != null) + return GetSupportedCommandsCached(b).ToList(); + else if (uri.Scheme.EndsWith("s", StringComparison.Ordinal)) + { + var tmpscheme = uri.Scheme.Substring(0, uri.Scheme.Length - 1); + if (m_interfaces.TryGetValue(tmpscheme, out b) && b != null) + return GetSupportedCommandsCached(b).ToList(); + } + + return null; + } + } + } + + /// + /// The static instance used to access SourceProvider information + /// + private static readonly SourceProviderLoaderSub _SourceProviderLoader = new SourceProviderLoaderSub(); + + #region Public static API + + /// + /// Gets a list of loaded SourceProviders, the instances can be used to extract interface information, not used to interact with the SourceProvider. + /// + public static ISourceProviderModule[] SourceProviders { get { return _SourceProviderLoader.Interfaces; } } + + /// + /// Gets a list of keys supported + /// + public static string[] Keys { get { return _SourceProviderLoader.Keys; } } + + /// + /// Gets the supported commands for a given SourceProvider + /// + /// The url to find the commands for + /// The supported commands or null if the url is not supported + public static IReadOnlyList GetSupportedCommands(string url) + { + if (string.IsNullOrEmpty(url)) + throw new ArgumentNullException(nameof(url)); + + var commands = _SourceProviderLoader.GetSupportedCommands(url); + if (commands != null) + return commands; + + var backend = BackendLoader.GetBackend(url, []); + if (backend is IFolderEnabledBackend folderBackend) + commands = folderBackend.SupportedCommands.AsReadOnly(); + backend?.Dispose(); + + return commands; + } + + /// + /// Instanciates a specific SourceProvider, given the url and options + /// + /// The url to create the instance for + /// The mount point to use + /// The options to pass to the instance constructor + /// The cancellation token + /// The instanciated SourceProvider or null if the url is not supported + public static async Task GetSourceProvider(string url, string mountPoint, Dictionary options, CancellationToken cancellationToken) + { + // Source providers are preferred over backends + var provider = _SourceProviderLoader.GetSourceProvider(url, mountPoint, options); + if (provider == null) + { + // See if there is a backend that can also be a source + var backend = BackendLoader.GetBackend(url, options); + if (backend is IFolderEnabledBackend folderBackend) + provider = new BackendSourceProvider(folderBackend, mountPoint); + else + backend?.Dispose(); + } + + if (provider == null) + return null; + + try + { + await provider.Initialize(cancellationToken).ConfigureAwait(false); + return provider; + } + catch + { + provider.Dispose(); + throw; + } + + } + #endregion + + /// + /// Adds a SourceProvider to the loader + /// + /// The SourceProvider to add + public static void AddSourceProvider(ISourceProviderModule SourceProvider) + { + _SourceProviderLoader.AddModule(SourceProvider); + } + } +} diff --git a/Duplicati/Library/Interface/BackendExtensions.cs b/Duplicati/Library/Interface/BackendExtensions.cs index 8844bd127..cf8c78f0b 100644 --- a/Duplicati/Library/Interface/BackendExtensions.cs +++ b/Duplicati/Library/Interface/BackendExtensions.cs @@ -18,10 +18,8 @@ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; + +using System.Threading; using System.Threading.Tasks; namespace Duplicati.Library.Interface @@ -36,12 +34,15 @@ namespace Duplicati.Library.Interface /// As long as the iteration can either complete or find at least one file without throwing, the test is successful /// /// Backend to test - public static void TestList(this IBackend backend) + /// Cancellation token + public static async Task TestListAsync(this IBackend backend, CancellationToken cancelToken) { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancelToken); // If we can iterate successfully, even if it's empty, then the backend test is successful - foreach (IFileEntry file in backend.List()) + await foreach (IFileEntry file in backend.ListAsync(cts.Token).ConfigureAwait(false)) { - break; + cts.Cancel(); + return; } } } diff --git a/Duplicati/Library/Interface/CustomExceptions.cs b/Duplicati/Library/Interface/CustomExceptions.cs index 5722ab0ad..27068d494 100644 --- a/Duplicati/Library/Interface/CustomExceptions.cs +++ b/Duplicati/Library/Interface/CustomExceptions.cs @@ -1,25 +1,26 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; +using System.Net.Http.Headers; using Duplicati.Library.Localization.Short; namespace Duplicati.Library.Interface @@ -225,4 +226,36 @@ namespace Duplicati.Library.Interface : base(Strings.Common.SettingsKeyMissingExceptionError, "SettingsKeyMissing") { } } + + /// + /// An exception for carrying the Retry-After header value on 429 Exceptions + /// + [Serializable] + public class TooManyRequestException(RetryConditionHeaderValue retryAfter) : Exception + { + public readonly RetryConditionHeaderValue RetryAfter = retryAfter; + } + + /// + /// An exception indicating that the database state is inconsistent + /// + [Serializable] + public class DatabaseInconsistencyException : UserInformationException + { + public DatabaseInconsistencyException(string message) + : base(message, "DatabaseInconsistency") + { } + + public DatabaseInconsistencyException(string message, string helpId) + : base(message, helpId) + { } + + public DatabaseInconsistencyException(string message, Exception innerException) + : base(message, "DatabaseInconsistency", innerException) + { } + + public DatabaseInconsistencyException(string message, string helpId, Exception innerException) + : base(message, helpId, innerException) + { } + } } diff --git a/Duplicati/Library/Interface/IBackend.cs b/Duplicati/Library/Interface/IBackend.cs index 26f41c6df..eee508a3a 100644 --- a/Duplicati/Library/Interface/IBackend.cs +++ b/Duplicati/Library/Interface/IBackend.cs @@ -51,8 +51,9 @@ namespace Duplicati.Library.Interface /// /// Enumerates a list of files found on the remote location /// + /// Token to cancel the operation. /// The list of files - IEnumerable List(); + IAsyncEnumerable ListAsync(CancellationToken cancellationToken); /// /// Puts the content of the file to the url passed diff --git a/Duplicati/Library/Interface/IDestinationProvider.cs b/Duplicati/Library/Interface/IDestinationProvider.cs new file mode 100644 index 000000000..20467a0b5 --- /dev/null +++ b/Duplicati/Library/Interface/IDestinationProvider.cs @@ -0,0 +1,43 @@ +#nullable enable + +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Duplicati.Library.Interface +{ + /// + /// Implements a destination where data can be restored to + /// + public interface IDestinationProvider : IDynamicModule + { + /// + /// Gets the module key + /// + string Key { get; } + /// + /// Gets the display name of the module + /// + string DisplayName { get; } + + /// + /// Checks if a restore is needed + /// + /// The path to restore to + /// The hash of the data to restore + /// The hash of the metadata to restore + /// The cancellation token + /// True if a restore is needed, false otherwise + Task IsRestoreNeededAsync(string path, string dataHash, string? metadataHash, CancellationToken cancel); + + /// + /// Restores a file to the given path + /// + /// The path to restore to + /// The data stream to restore + /// The metadata stream to restore + /// The cancellation token + /// A task that completes when the restore is done + Task WriteAsync(string path, Stream data, Stream? metadata, CancellationToken cancel); + } +} \ No newline at end of file diff --git a/Duplicati/Library/Interface/IFileEntry.cs b/Duplicati/Library/Interface/IFileEntry.cs index 9367c3105..9a8a4a04b 100644 --- a/Duplicati/Library/Interface/IFileEntry.cs +++ b/Duplicati/Library/Interface/IFileEntry.cs @@ -41,6 +41,10 @@ namespace Duplicati.Library.Interface /// DateTime LastModification { get; } /// + /// The time the file or folder was created + /// + DateTime Created { get; } + /// /// The name of the file or folder /// string Name { get; } diff --git a/Duplicati/Library/Interface/IFolderEnabledBackend.cs b/Duplicati/Library/Interface/IFolderEnabledBackend.cs new file mode 100644 index 000000000..6f9374aed --- /dev/null +++ b/Duplicati/Library/Interface/IFolderEnabledBackend.cs @@ -0,0 +1,61 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +#nullable enable + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Duplicati.Library.Interface; + +/// +/// An interface a backend may implement if it supports streaming operations. +/// Backends that implement this interface can be throttled and correctly shows +/// the progressbar when transferring data. +/// +public interface IFolderEnabledBackend : IBackend +{ + /// + /// Enumerates a list of files and folders found on the remote location + /// + /// The path to list, empty or null for the root folder + /// The cancellation token + /// The list of files, where paths are scoped to the backend. + /// + /// The paths returned should be only the filenames or folder names, not the full path. + /// Folders should end with the operating system's directory separator. + /// + IAsyncEnumerable ListAsync(string? path, CancellationToken cancellationToken); + + /// + /// Gets a single file or folder entry + /// + /// The path to get + /// The cancellation token + /// The file or folder entry + /// + /// The paths returned should be only the filenames or folder names, not the full path. + /// Folders should end with the operating system's directory separator. + /// + Task GetEntryAsync(string path, CancellationToken cancellationToken); +} + diff --git a/Duplicati/Library/Interface/ISourceProvider.cs b/Duplicati/Library/Interface/ISourceProvider.cs new file mode 100644 index 000000000..6ac0b9ea0 --- /dev/null +++ b/Duplicati/Library/Interface/ISourceProvider.cs @@ -0,0 +1,63 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Duplicati.Library.Interface; + +/// +/// Implements a data source that can be backed up +/// +public interface ISourceProvider : IDisposable +{ + /// + /// Returns the path where this provider is logically mounted + /// + string MountedPath { get; } + + /// + /// Initializes the provider, if needed + /// + /// The cancellation token + /// An awaitable task + Task Initialize(CancellationToken cancellationToken); + + /// + /// Gets the root entries + /// + /// The cancellation token + /// An enumerable of file entries + IAsyncEnumerable Enumerate(CancellationToken cancellationToken); + + /// + /// Gets a specific entry + /// + /// The path to use + /// True if the path is a folder + /// The cancellation token + /// The file entry + Task GetEntry(string path, bool isFolder, CancellationToken cancellationToken); +} diff --git a/Duplicati/Library/Interface/ISourceProviderEntry.cs b/Duplicati/Library/Interface/ISourceProviderEntry.cs new file mode 100644 index 000000000..c93d945e2 --- /dev/null +++ b/Duplicati/Library/Interface/ISourceProviderEntry.cs @@ -0,0 +1,126 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +#nullable enable + +namespace Duplicati.Library.Interface; + +/// +/// Interface for an instance of a file or folder +/// +public interface ISourceProviderEntry +{ + /// + /// True if the entry represents a folder, false otherwise + /// + bool IsFolder { get; } + /// + /// True if the entry is a meta entry, false otherwise + /// + bool IsMetaEntry { get; } + /// + /// True if the entry is the root entry, false otherwise + /// + bool IsRootEntry { get; } + /// + /// The creation time of the file or folder + /// + DateTime CreatedUtc { get; } + /// + /// The time the file or folder was last modified + /// + DateTime LastModificationUtc { get; } + /// + /// The name of the file or folder + /// + string Path { get; } + /// + /// The size of the file or folder + /// + long Size { get; } + /// + /// True if the file is a symlink, false otherwise + /// + bool IsSymlink { get; } + /// + /// The target of a symlink, if the file is a symlink + /// + string? SymlinkTarget { get; } + /// + /// The entry attributes + /// + FileAttributes Attributes { get; } + /// + /// The minor metadata of the file, should be less than 1KB + /// + Dictionary MinorMetadata { get; } + /// + /// True if the file is a block device, false otherwise + /// + bool IsBlockDevice { get; } + /// + /// True if the file is a character device, false otherwise + /// + bool IsCharacterDevice { get; } + /// + /// True if the files is an alternate stream, false otherwise + /// + bool IsAlternateStream { get; } + /// + /// The hardlink target id, if the file is a hardlink + /// + string? HardlinkTargetId { get; } + + /// + /// Opens the file for reading + /// + /// A token that can be used to cancel the operation + /// A stream that can be read + Task OpenRead(CancellationToken cancellationToken); + + /// + /// Opens the metadata for reading + /// + /// A token that can be used to cancel the operation + /// A stream that can be read + Task OpenMetadataRead(CancellationToken cancellationToken); + + /// + /// Checks if the specified file exists + /// + /// The file to check for existence + /// A token that can be used to cancel the operation + /// true if the file exists, false otherwise + Task FileExists(string filename, CancellationToken cancellationToken); + + /// + /// Returns the contents of the object, if it is a folder + /// + /// A token that can be used to cancel the operation + /// The contents of the folder + IAsyncEnumerable Enumerate(CancellationToken cancellationToken); +} diff --git a/Duplicati/Library/Interface/ISourceProviderModule.cs b/Duplicati/Library/Interface/ISourceProviderModule.cs new file mode 100644 index 000000000..469db1fb0 --- /dev/null +++ b/Duplicati/Library/Interface/ISourceProviderModule.cs @@ -0,0 +1,39 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +#nullable enable + +namespace Duplicati.Library.Interface; + +/// +/// Implements a data source that can be backed up +/// +public interface ISourceProviderModule : ISourceProvider, IDynamicModule +{ + /// + /// Gets the module key + /// + string Key { get; } + /// + /// Gets the display name of the module + /// + string DisplayName { get; } +} diff --git a/Duplicati/Library/Interface/ResultInterfaces.cs b/Duplicati/Library/Interface/ResultInterfaces.cs index 1848e98ca..0c886c43b 100644 --- a/Duplicati/Library/Interface/ResultInterfaces.cs +++ b/Duplicati/Library/Interface/ResultInterfaces.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -38,7 +38,7 @@ namespace Duplicati.Library.Interface DateTime BeginTime { get; } DateTime EndTime { get; } TimeSpan Duration { get; } - + IEnumerable Errors { get; } IEnumerable Warnings { get; } IEnumerable Messages { get; } @@ -64,6 +64,7 @@ namespace Duplicati.Library.Interface long UnknownFileCount { get; set; } long KnownFileCount { get; set; } long KnownFileSize { get; set; } + long KnownFilesets { get; set; } DateTime LastBackupDate { get; set; } long BackupListCount { get; set; } long TotalQuotaSpace { get; set; } @@ -116,14 +117,14 @@ namespace Duplicati.Library.Interface IEnumerable LogMessages { get; } IEnumerable RemoteVolumes { get; } } - + public interface IDeleteResults : IBasicResults { IEnumerable> DeletedSets { get; } ICompactResults CompactResults { get; } bool Dryrun { get; } } - + public interface IBackupResults : IBasicResults, IBackendStatsticsReporter { long DeletedFiles { get; } @@ -143,16 +144,16 @@ namespace Duplicati.Library.Interface long ModifiedFolders { get; } long ModifiedSymlinks { get; } long AddedSymlinks { get; } - long DeletedSymlinks { get; } + long DeletedSymlinks { get; } bool PartialBackup { get; } bool Dryrun { get; } - + ICompactResults CompactResults { get; } IVacuumResults VacuumResults { get; } IDeleteResults DeleteResults { get; } IRepairResults RepairResults { get; } } - + public interface IRestoreResults : IBasicResults { long RestoredFiles { get; } @@ -163,10 +164,10 @@ namespace Duplicati.Library.Interface long DeletedFiles { get; } long DeletedFolders { get; } long DeletedSymlinks { get; } - + IRecreateDatabaseResults RecreateDatabaseResults { get; } } - + public interface IRecreateDatabaseResults : IBasicResults { } @@ -188,23 +189,23 @@ namespace Duplicati.Library.Interface IVacuumResults VacuumResults { get; } } - + public interface ICreateLogDatabaseResults : IBasicResults { string TargetPath { get; } } - + public interface IRestoreControlFilesResults : IBasicResults { IEnumerable Files { get; } } - + public interface IRepairResults : IBasicResults { IRecreateDatabaseResults RecreateDatabaseResults { get; } } - - + + /// /// The possible change types for an entry /// @@ -222,8 +223,8 @@ namespace Duplicati.Library.Interface /// The element was modified /// Modified - } - + } + /// /// The possible entry types /// @@ -242,16 +243,16 @@ namespace Duplicati.Library.Interface /// File } - + public interface IListChangesResults : IBasicResults { DateTime BaseVersionTimestamp { get; } DateTime CompareVersionTimestamp { get; } long BaseVersionIndex { get; } long CompareVersionIndex { get; } - - IEnumerable> ChangeDetails { get; } - + + IEnumerable> ChangeDetails { get; } + long AddedFolders { get; } long AddedSymlinks { get; } long AddedFiles { get; } @@ -263,14 +264,14 @@ namespace Duplicati.Library.Interface long ModifiedFolders { get; } long ModifiedSymlinks { get; } long ModifiedFiles { get; } - + long PreviousSize { get; } long CurrentSize { get; } long AddedSize { get; } long DeletedSize { get; } } - + /// /// The status of a change in a test entry /// @@ -298,7 +299,7 @@ namespace Duplicati.Library.Interface { IEnumerable>>> Verifications { get; } } - + public interface ITestFilterResults : IBasicResults { long FileSize { get; set; } diff --git a/Duplicati/Library/Localization/ILocalizationService.cs b/Duplicati/Library/Localization/ILocalizationService.cs index c0c35f262..f9eeb825f 100644 --- a/Duplicati/Library/Localization/ILocalizationService.cs +++ b/Duplicati/Library/Localization/ILocalizationService.cs @@ -1,31 +1,35 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. -using System; -using System.Collections.Generic; +using System.Globalization; namespace Duplicati.Library.Localization { public interface ILocalizationService { + /// + /// The culture of the localization service + /// + public CultureInfo Culture { get; } + /// /// Localizes the string similar to how string.Format works /// diff --git a/Duplicati/Library/Localization/LocalizationContext.cs b/Duplicati/Library/Localization/LocalizationContext.cs index d2162bd05..9fed23cbb 100644 --- a/Duplicati/Library/Localization/LocalizationContext.cs +++ b/Duplicati/Library/Localization/LocalizationContext.cs @@ -1,26 +1,26 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. -using Duplicati.Library.Logging; using System; +using System.Threading; namespace Duplicati.Library.Localization { @@ -29,10 +29,14 @@ namespace Duplicati.Library.Localization /// internal class LocalizationContext : IDisposable { + /// + /// The current culture, using an async local to keep the call context + /// + private static readonly AsyncLocal _currentCulture = new(); /// /// The previous context /// - private readonly object m_prev; + private readonly string m_prev; /// /// Flag to prevent double dispose @@ -45,12 +49,17 @@ namespace Duplicati.Library.Localization /// The localization to use. public LocalizationContext(System.Globalization.CultureInfo ci) { - m_prev = CallContext.GetData(LocalizationService.LOGICAL_CONTEXT_KEY) as string; - CallContext.SetData(LocalizationService.LOGICAL_CONTEXT_KEY, ci.Name); + m_prev = _currentCulture.Value; + _currentCulture.Value = ci.Name; m_isDisposed = false; } + /// + /// Returns the current culture value for the call context + /// + public static string Current => _currentCulture.Value; + /// /// Releases all resource used by the object. /// @@ -64,7 +73,7 @@ namespace Duplicati.Library.Localization { if (!m_isDisposed) { - CallContext.SetData(LocalizationService.LOGICAL_CONTEXT_KEY, m_prev); + _currentCulture.Value = m_prev; m_isDisposed = true; } } diff --git a/Duplicati/Library/Localization/LocalizationService.cs b/Duplicati/Library/Localization/LocalizationService.cs index b86b67182..636a75217 100644 --- a/Duplicati/Library/Localization/LocalizationService.cs +++ b/Duplicati/Library/Localization/LocalizationService.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -24,7 +24,6 @@ using System.Linq; using System.Globalization; using System.Collections.Generic; using System.Text.RegularExpressions; -using Duplicati.Library.Localization.Short; using Duplicati.Library.Logging; namespace Duplicati.Library.Localization @@ -39,11 +38,6 @@ namespace Duplicati.Library.Localization /// private static readonly Dictionary Services = new Dictionary(); - /// - /// The key for accessing logical context - /// - internal const string LOGICAL_CONTEXT_KEY = "DUPLICATI_LOCALIZATION_CULTURE_CONTEXT"; - /// /// Regular expression to match a locale /// @@ -97,13 +91,27 @@ namespace Duplicati.Library.Localization { get { - var lc = CallContext.GetData(LOGICAL_CONTEXT_KEY) as string; + var lc = LocalizationContext.Current; if (!string.IsNullOrWhiteSpace(lc)) return Get(new CultureInfo(lc)); return Get(CultureInfo.CurrentCulture); } } + /// + /// Gets a localization provider with the current UI language + /// + public static ILocalizationService CurrentUI + { + get + { + var lc = LocalizationContext.Current; + if (!string.IsNullOrWhiteSpace(lc)) + return Get(new CultureInfo(lc)); + return Get(CultureInfo.CurrentUICulture); + } + } + /// /// Gets a localization provider with the OS install language /// @@ -147,7 +155,7 @@ namespace Duplicati.Library.Localization /// /// Returns true if the culture has localization support /// - public static Boolean isCultureSupported(CultureInfo culture) + public static bool IsCultureSupported(CultureInfo culture) { return MoLocalizationService.isCultureSupported(culture); } diff --git a/Duplicati/Library/Localization/MoLocalizationService.cs b/Duplicati/Library/Localization/MoLocalizationService.cs index e0a81fbbb..a5983cf77 100644 --- a/Duplicati/Library/Localization/MoLocalizationService.cs +++ b/Duplicati/Library/Localization/MoLocalizationService.cs @@ -75,6 +75,7 @@ namespace Duplicati.Library.Localization /// The culture to find. public MoLocalizationService(CultureInfo ci) { + Culture = ci; var filenames = new string[] { // Load the specialized version first string.Format("localization-{0}.mo", ci.Name.Replace('-', '_')), @@ -117,6 +118,11 @@ namespace Duplicati.Library.Localization } } + /// + /// Gets the culture of the localization + /// + public CultureInfo Culture { get; } + /// /// Pre-processes the message to use Linux line endings /// diff --git a/Duplicati/Library/Localization/MockLocalizationService.cs b/Duplicati/Library/Localization/MockLocalizationService.cs index d0e894cf9..1e2ee86e9 100644 --- a/Duplicati/Library/Localization/MockLocalizationService.cs +++ b/Duplicati/Library/Localization/MockLocalizationService.cs @@ -1,31 +1,35 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. -using System; -using System.Collections.Generic; +using System.Globalization; namespace Duplicati.Library.Localization { public class MockLocalizationService : ILocalizationService { + /// + /// The culture of the localization service + /// + public CultureInfo Culture => CultureInfo.InvariantCulture; + /// /// Localizes the string similar to how string.Format works /// diff --git a/Duplicati/Library/Logging/CallContext.cs b/Duplicati/Library/Logging/CallContext.cs deleted file mode 100644 index dfb6e8f36..000000000 --- a/Duplicati/Library/Logging/CallContext.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. -using System.Collections.Concurrent; -using System.Threading; - -namespace Duplicati.Library.Logging -{ - /// - /// Provides a way to set contextual data that flows with the call and - /// async context of a test or invocation. - /// - public static class CallContext - { - static ConcurrentDictionary> state = new ConcurrentDictionary>(); - - /// - /// Stores a given object and associates it with the specified name. - /// - /// The name with which to associate the new item in the call context. - /// The object to store in the call context. - public static void SetData(string name, object data) => - state.GetOrAdd(name, _ => new AsyncLocal()).Value = data; - - /// - /// Retrieves an object with the specified name from the . - /// - /// The name of the item in the call context. - /// The object in the call context associated with the specified name, or if not found. - public static object GetData(string name) => - state.TryGetValue(name, out AsyncLocal data) ? data.Value : null; - } -} \ No newline at end of file diff --git a/Duplicati/Library/Logging/Log.cs b/Duplicati/Library/Logging/Log.cs index bea2236ab..d53a9de3f 100644 --- a/Duplicati/Library/Logging/Log.cs +++ b/Duplicati/Library/Logging/Log.cs @@ -1,27 +1,27 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; -using System.Text; +using System.Threading; namespace Duplicati.Library.Logging { @@ -72,18 +72,13 @@ namespace Duplicati.Library.Logging /// /// The key used to assign the current scope into the current call-context /// - private const string LOGICAL_CONTEXT_KEY = "Duplicati:LoggingEntry"; + private static readonly AsyncLocal _currentScope = new AsyncLocal(); /// /// The root scope /// private static readonly LogScope m_root = new LogScope(null, new LogTagFilter(LogMessageType.Error, null, null), null, true); - /// - /// The stored log instances - /// - private static readonly Dictionary m_log_instances = new Dictionary(); - /// /// Static lock object to provide thread safe logging /// @@ -216,15 +211,15 @@ namespace Duplicati.Library.Logging WriteMessage(LogMessageType.Verbose, tag, id, null, message, arguments); } - /// + /// /// Writes a verbose message to the current log destination /// /// The message to write /// The tag-type for this message /// The message id - /// The exception to log + /// The exception to log /// The message format arguments - public static void WriteVerboseMessage(string tag, string id, Exception ex, string message, params object[] arguments) + public static void WriteVerboseMessage(string tag, string id, Exception ex, string message, params object[] arguments) { WriteMessage(LogMessageType.Verbose, tag, id, ex, message, arguments); } @@ -428,11 +423,10 @@ namespace Duplicati.Library.Logging /// The scope to finish. internal static void CloseScope(LogScope scope) { - lock(m_lock) + lock (m_lock) { if (CurrentScope == scope && scope != m_root) CurrentScope = scope.Parent; - m_log_instances.Remove(scope.InstanceID); } } @@ -444,32 +438,12 @@ namespace Duplicati.Library.Logging get { lock (m_lock) - { - var cur = CallContext.GetData(LOGICAL_CONTEXT_KEY) as string; - if (cur == null || cur == m_root.InstanceID) - return m_root; - - LogScope sc; - if (!m_log_instances.TryGetValue(cur, out sc)) - throw new Exception("Unable to find log in lookup table, this may be caused by attempting to transport call contexts between AppDomains (e.g. with remoting calls)"); - - return sc; - } + return _currentScope.Value ?? m_root; } private set { lock (m_lock) - { - if (value != null) - { - m_log_instances[value.InstanceID] = value; - CallContext.SetData(LOGICAL_CONTEXT_KEY, value.InstanceID); - } - else - { - CallContext.SetData(LOGICAL_CONTEXT_KEY, null); - } - } + _currentScope.Value = value; } } } diff --git a/Duplicati/Library/Main/Backend/BackendManager.DeleteOperation.cs b/Duplicati/Library/Main/Backend/BackendManager.DeleteOperation.cs index a846328b3..8b313aafa 100644 --- a/Duplicati/Library/Main/Backend/BackendManager.DeleteOperation.cs +++ b/Duplicati/Library/Main/Backend/BackendManager.DeleteOperation.cs @@ -72,7 +72,7 @@ partial class BackendManager try { - recovered = !backend.List().Select(x => x.Name).Contains(RemoteFilename); + recovered = !await backend.ListAsync(cancelToken).Select(x => x.Name).ContainsAsync(RemoteFilename).ConfigureAwait(false); } catch { diff --git a/Duplicati/Library/Main/Backend/BackendManager.GetOperation.cs b/Duplicati/Library/Main/Backend/BackendManager.GetOperation.cs index 14bd9a2bf..d159ef046 100644 --- a/Duplicati/Library/Main/Backend/BackendManager.GetOperation.cs +++ b/Duplicati/Library/Main/Backend/BackendManager.GetOperation.cs @@ -31,6 +31,10 @@ partial class BackendManager /// public override long Size { get; } /// + /// Flag indicating whether the file should be decrypted + /// + public bool Decrypt { get; set; } + /// /// The hash of the remote file, or null if unknown /// public required string? Hash { get; set; } @@ -64,11 +68,6 @@ partial class BackendManager { TempFile? tmpfile = null; Context.Statwriter.SendEvent(BackendActionType.Get, BackendEventType.Started, RemoteFilename, Size); - using var encryption = Context.Options.NoEncryption - ? null - : (DynamicLoader.EncryptionLoader.GetModule(Context.Options.EncryptionModule, Context.Options.Passphrase, Context.Options.RawOptions) - ?? throw new Exception(Strings.BackendMananger.EncryptionModuleNotFound(Context.Options.EncryptionModule)) - ); try { @@ -78,7 +77,8 @@ partial class BackendManager (tmpfile, var dataSizeDownloaded, var fileHash) = await DoGetFileAsync(backend, cancelToken).ConfigureAwait(false); var duration = DateTime.Now - begin; - Logging.Log.WriteProfilingMessage(LOGTAG, "DownloadSpeed", "Downloaded {0} in {1}, {2}/s", Library.Utility.Utility.FormatSizeString(dataSizeDownloaded), + Logging.Log.WriteProfilingMessage(LOGTAG, "DownloadSpeed", "Downloaded {0} to {1} ({2}) in {3}, {4}/s", + RemoteFilename, tmpfile.Name, Library.Utility.Utility.FormatSizeString(dataSizeDownloaded), duration, Library.Utility.Utility.FormatSizeString((long)(dataSizeDownloaded / duration.TotalSeconds))); Context.Database.LogRemoteOperation("get", RemoteFilename, System.Text.Json.JsonSerializer.Serialize(new { Size = dataSizeDownloaded, Hash = fileHash })); @@ -94,7 +94,8 @@ partial class BackendManager } // Perform decryption after hash validation, if needed - tmpfile = DecryptFile(tmpfile, DetectEncryptionModule(encryption)); + if (Decrypt) + tmpfile = DecryptFile(tmpfile, RemoteFilename, Context.Options); return (tmpfile, fileHash, dataSizeDownloaded); } @@ -123,16 +124,14 @@ partial class BackendManager { // extended to use stacked streams using (var fs = System.IO.File.OpenWrite(dlTarget)) - using (var act = new StreamUtil.TimeoutObservingStream(fs) { WriteTimeout = backend is ITimeoutExemptBackend ? Timeout.Infinite : Context.Options.ReadWriteTimeout }) using (var hasher = HashFactory.CreateHasher(Context.Options.FileHashAlgorithm)) - using (var hs = new HashCalculatingStream(act, hasher)) + using (var hs = new HashCalculatingStream(fs, hasher)) using (var ss = new ShaderStream(hs, true)) - using (var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(cancelToken, act.TimeoutToken)) { using (var ts = new ThrottledStream(ss, 0, Context.Options.MaxDownloadPrSecond)) using (var pgs = new ProgressReportingStream(ts, pg => Context.HandleProgress(ts, pg, RemoteFilename))) { - await streamingBackend.GetAsync(RemoteFilename, pgs, linkedToken.Token).ConfigureAwait(false); + await streamingBackend.GetAsync(RemoteFilename, pgs, cancelToken).ConfigureAwait(false); } ss.Flush(); retDownloadSize = ss.TotalBytesWritten; @@ -163,40 +162,40 @@ partial class BackendManager /// /// The default encryption module /// The encryption module to use - private IEncryption? DetectEncryptionModule(IEncryption? encryption) + private static IEncryption? DetectEncryptionModule(string filename, Options options, IEncryption? encryption) { try { // Auto-guess the encryption module - var ext = (System.IO.Path.GetExtension(RemoteFilename) ?? "").TrimStart('.'); + var ext = (System.IO.Path.GetExtension(filename) ?? "").TrimStart('.'); if (!ext.Equals(encryption?.FilenameExtension, StringComparison.OrdinalIgnoreCase)) { // Check if the file is not encrypted if (DynamicLoader.CompressionLoader.Keys.Contains(ext, StringComparer.OrdinalIgnoreCase)) { if (encryption != null) - Logging.Log.WriteVerboseMessage(LOGTAG, "AutomaticDecryptionDetection", "Filename extension \"{0}\" does not match encryption module \"{1}\", guessing that it is not encrypted", ext, Context.Options.EncryptionModule); + Logging.Log.WriteVerboseMessage(LOGTAG, "AutomaticDecryptionDetection", "Filename extension \"{0}\" does not match encryption module \"{1}\", guessing that it is not encrypted", ext, options.EncryptionModule); return null; } // Check if the file is encrypted with something else else if (DynamicLoader.EncryptionLoader.Keys.Contains(ext, StringComparer.OrdinalIgnoreCase)) { - Logging.Log.WriteVerboseMessage(LOGTAG, "AutomaticDecryptionDetection", "Filename extension \"{0}\" does not match encryption module \"{1}\", attempting to use matching encryption module", ext, Context.Options.EncryptionModule); + Logging.Log.WriteVerboseMessage(LOGTAG, "AutomaticDecryptionDetection", "Filename extension \"{0}\" does not match encryption module \"{1}\", attempting to use matching encryption module", ext, options.EncryptionModule); try { - return DynamicLoader.EncryptionLoader.GetModule(ext, Context.Options.Passphrase, Context.Options.RawOptions) + return DynamicLoader.EncryptionLoader.GetModule(ext, options.Passphrase, options.RawOptions) ?? encryption; } catch (Exception ex) { - Logging.Log.WriteWarningMessage(LOGTAG, "AutomaticDecryptionDetection", ex, "Failed to load encryption module \"{0}\", using specified encryption module \"{1}\"", ext, Context.Options.EncryptionModule); + Logging.Log.WriteWarningMessage(LOGTAG, "AutomaticDecryptionDetection", ex, "Failed to load encryption module \"{0}\", using specified encryption module \"{1}\"", ext, options.EncryptionModule); } } // Fallback, lets see what happens... else { - Logging.Log.WriteVerboseMessage(LOGTAG, "AutomaticDecryptionDetection", "Filename extension \"{0}\" does not match encryption module \"{1}\", attempting to use specified encryption module as no others match", ext, Context.Options.EncryptionModule); + Logging.Log.WriteVerboseMessage(LOGTAG, "AutomaticDecryptionDetection", "Filename extension \"{0}\" does not match encryption module \"{1}\", attempting to use specified encryption module as no others match", ext, options.EncryptionModule); } } @@ -215,7 +214,7 @@ partial class BackendManager /// The encrypted file /// Then encryption module to use, or null for no encryption /// The decrypted file - private TempFile DecryptFile(TempFile tempFile, IEncryption? decrypter) + private static TempFile DecryptFile(TempFile tempFile, IEncryption? decrypter) { // Support no encryption if (decrypter == null) @@ -246,5 +245,22 @@ partial class BackendManager } } } + + /// + /// Decrypts a file using the specified options + /// + /// The file to decrypt + /// The name of the file. Used for detecting encryption algorithm if not specified in options or if it differs from the options + /// The Duplicati options + /// The decrypted file + public static TempFile DecryptFile(TempFile tmpfile, string filename, Options options) + { + using var encryption = options.NoEncryption + ? null + : (DynamicLoader.EncryptionLoader.GetModule(options.EncryptionModule, options.Passphrase, options.RawOptions) + ?? throw new Exception(Strings.BackendMananger.EncryptionModuleNotFound(options.EncryptionModule)) + ); + return DecryptFile(tmpfile, DetectEncryptionModule(filename, options, encryption)); + } } } \ No newline at end of file diff --git a/Duplicati/Library/Main/Backend/BackendManager.Handler.cs b/Duplicati/Library/Main/Backend/BackendManager.Handler.cs index b059a0f1f..cf1198926 100644 --- a/Duplicati/Library/Main/Backend/BackendManager.Handler.cs +++ b/Duplicati/Library/Main/Backend/BackendManager.Handler.cs @@ -88,10 +88,14 @@ partial class BackendManager /// private static readonly string LOGTAG = Logging.Log.LogTagFromType(); + /// + /// The list of active downloads + /// + private readonly List activeDownloads = []; /// /// The list of active uploads /// - private readonly List activeUploads = new(); + private readonly List activeUploads = []; /// /// The pool of backends currently created /// @@ -105,6 +109,10 @@ partial class BackendManager /// private readonly ExecuteContext context; /// + /// The maximum number of parallel downloads + /// + private readonly int maxParallelDownloads; + /// /// The maximum number of parallel uploads /// private readonly int maxParallelUploads; @@ -155,6 +163,8 @@ partial class BackendManager this.backendUrl = backendUrl; this.context = context; + // TODO Currently, only the restore process uses parallel downloads. If others need it as well, maybe use another option. + maxParallelDownloads = Math.Max(1, context.Options.RestoreVolumeDownloaders); maxParallelUploads = Math.Max(1, context.Options.AsynchronousConcurrentUploadLimit); maxRetries = context.Options.NumberOfRetries; retryDelay = context.Options.RetryDelay; @@ -181,36 +191,61 @@ partial class BackendManager } /// - /// Reclaims completed uploads + /// Reclaims completed tasks /// + /// The list of tasks to reclaim /// An awaitable task - private async Task ReclaimCompletedUploads() + private static async Task ReclaimCompletedTasks(List tasks) { - for (int i = activeUploads.Count - 1; i >= 0; i--) + for (int i = tasks.Count - 1; i >= 0; i--) { - if (activeUploads[i].IsCompleted) + if (tasks[i].IsCompleted) { + var t = tasks[i]; + tasks.RemoveAt(i); // Make sure the task is awaited so we capture any exceptions - await activeUploads[i].ConfigureAwait(false); - activeUploads.RemoveAt(i); + await t.ConfigureAwait(false); } } } /// - /// Ensures that there are at most N - 1 pending uploads + /// Reclaims completed tasks from uploads and downloads /// - /// The maximum number of pending uploads /// An awaitable task - private async Task EnsureAtMostNPendingUploads(int n) + private async Task ReclaimCompletedTasks() { - while (activeUploads.Count >= n) + await ReclaimCompletedTasks(activeUploads); + await ReclaimCompletedTasks(activeDownloads); + } + + /// + /// Ensures that there are at most N - 1 active tasks + /// + /// The maximum number of active tasks + /// The list of active tasks + /// An awaitable task + private static async Task EnsureAtMostNActiveTasks(int n, List tasks) + { + while (tasks.Count >= n) { - await Task.WhenAny(activeUploads).ConfigureAwait(false); - await ReclaimCompletedUploads().ConfigureAwait(false); + await Task.WhenAny(tasks).ConfigureAwait(false); + await ReclaimCompletedTasks(tasks).ConfigureAwait(false); } } + /// + /// Ensures that there are at most N - 1 active tasks + /// + /// The number of active uploads + /// The number of active downloads + /// An awaitable task + private async Task EnsureAtMostNActiveTasks(int uploads, int downloads) + { + await EnsureAtMostNActiveTasks(uploads, activeUploads).ConfigureAwait(false); + await EnsureAtMostNActiveTasks(downloads, activeDownloads).ConfigureAwait(false); + } + /// /// Runs the handler /// @@ -218,39 +253,109 @@ partial class BackendManager /// An awaitable task private async Task Run(IReadChannel requestChannel) { - while (true) + using var tcs = new CancellationTokenSource(); + try { - // Get next operation - var op = await requestChannel.ReadAsync().ConfigureAwait(false); - - try + while (true) { - // Clean up completed uploads, if any - await ReclaimCompletedUploads().ConfigureAwait(false); + // Get next operation + var op = await requestChannel.ReadAsync().ConfigureAwait(false); - // Allow PUT operations to be queued, if requested - if (op is PutOperation putOp && !putOp.WaitForComplete) + try { - await EnsureAtMostNPendingUploads(maxParallelUploads).ConfigureAwait(false); + // Clean up completed uploads, if any + await ReclaimCompletedTasks().ConfigureAwait(false); - // Operation is accepted into queue, so we can signal completion - putOp.SetComplete(true); - activeUploads.Add(ExecuteWithRetry(putOp)); + // Allow PUT operations to be queued, if requested + if (op is PutOperation putOp && !putOp.WaitForComplete) + { + // Wait for any active downloads to complete before starting an upload + await EnsureAtMostNActiveTasks(maxParallelUploads, 1).ConfigureAwait(false); + + // Operation is accepted into queue, so we can signal completion + putOp.SetComplete(true); + activeUploads.Add(ExecuteWithRetry(putOp, tcs.Token)); + } + else if (op is GetOperation getOp) + { + // Wait for any active uploads to complete before starting a download + await EnsureAtMostNActiveTasks(1, maxParallelDownloads).ConfigureAwait(false); + + // Operation is accepted into queue, so we can signal completion + activeDownloads.Add(ExecuteWithRetry(getOp, tcs.Token)); + } + else + { + // Wait for all of the active uploads and downloads to complete + await EnsureAtMostNActiveTasks(1, 1).ConfigureAwait(false); + + // Execute the operation + await ExecuteWithRetry(op, tcs.Token).ConfigureAwait(false); + } + } + catch (Exception ex) + { + Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerHandlerFailure", ex, "Error in handler: {0}", ex.Message); + // If we fail, the task may "hang", so we ensure it is completed here + op.SetFailed(ex); + throw; + } + } + } + finally + { + // Terminate any active uploads and downloads. Exceptions thrown by the downloads should be captured by the callers. + tcs.Cancel(); + + if (activeUploads.Count > 0) + { + Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeWhileActive", null, "Terminating {0} active uploads", activeUploads.Count); + + await WaitForPendingItems("upload", activeUploads).ConfigureAwait(false); + await WaitForPendingItems("download", activeDownloads).ConfigureAwait(false); + + // Dispose of any remaining backends + while (backendPool.TryDequeue(out var backend)) + try { backend.Dispose(); } + catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeError", ex, "Failed to dispose backend instance: {0}", ex.Message); } + } + } + } + + /// + /// + /// + /// + /// + /// + private static async Task WaitForPendingItems(string description, List tasks) + { + if (tasks.Count > 0) + { + Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeWhileActive", null, "Terminating {0} active {1}s", tasks.Count, description); + + // Wait for all active tasks to complete + await Task.WhenAny(Task.Delay(1000), Task.WhenAll(tasks)).ConfigureAwait(false); + for (int i = tasks.Count - 1; i >= 0; i--) + { + var t = tasks[i]; + if (t.IsCompleted) + { + tasks.RemoveAt(i); + if (t.IsCanceled) + Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeError", t.Exception, "Error in active {0}: Cancelled", description); + else if (t.IsFaulted) + Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeError", t.Exception, "Error in active {0}: {1}", description, t.Exception?.Message ?? "null"); + else + Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeError", null, "{0} was active during termination, but completed successfully", description); } else { - // Wait for all pending uploads to complete - await EnsureAtMostNPendingUploads(1).ConfigureAwait(false); - // Execute the operation - await ExecuteWithRetry(op).ConfigureAwait(false); + Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeError", null, "{0} was active during termination, but had state: {1}", description, t.Status); } - } - catch (Exception ex) - { - Logging.Log.WriteWarningMessage(LOGTAG, "HandlerError", ex, "Error in handler: {0}", ex.Message); - // If we fail, the task may "hang", so we ensure it is completed here - op.SetFailed(ex); - throw; + + if (tasks.Count > 0) + Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeError", null, "Terminating, but {0} active {1}(s) are still active", tasks.Count, description); } } } @@ -282,8 +387,9 @@ partial class BackendManager /// Executes an operation with retries and error handling /// /// The operation to execute + /// The cancellation token /// An awaitable task - private async Task ExecuteWithRetry(PendingOperationBase op) + private async Task ExecuteWithRetry(PendingOperationBase op, CancellationToken cancellationToken) { // Once in this method, we MUST set the op result, // or the program will hang waiting for the operation to complete @@ -296,7 +402,7 @@ partial class BackendManager try { // Happy case is execute and return - await Execute(op).ConfigureAwait(false); + await Execute(op, cancellationToken).ConfigureAwait(false); return; } catch (Exception ex) @@ -328,7 +434,7 @@ partial class BackendManager } } - context.Statwriter.SendEvent(op.Operation, retries < maxRetries ? BackendEventType.Retrying : BackendEventType.Failed, op.RemoteFilename, op.Size); + context.Statwriter.SendEvent(op.Operation, retries <= maxRetries ? BackendEventType.Retrying : BackendEventType.Failed, op.RemoteFilename, op.Size); // Check if we can recover from the error var recovered = false; @@ -341,14 +447,14 @@ partial class BackendManager } // We did not recover, so wait or give up - if (!recovered && retries < maxRetries && retryDelay.Ticks != 0) + if (!recovered && retries <= maxRetries && retryDelay.Ticks != 0) { var delay = Library.Utility.Utility.GetRetryDelay(retryDelay, retries, retryWithExponentialBackoff); await Task.Delay(delay, context.TaskReader.ProgressToken).ConfigureAwait(false); } } - } while (retries < maxRetries); + } while (retries <= maxRetries); // If we have a last exception, we failed if (lastException != null) @@ -369,29 +475,30 @@ partial class BackendManager /// but avoids a reflection-based dispatch. /// /// The operation to execute + /// The cancellation token /// An awaitable task - private async Task Execute(PendingOperationBase op) + private async Task Execute(PendingOperationBase op, CancellationToken cancellationToken) { await context.TaskReader.ProgressRendevouz().ConfigureAwait(false); using (new Logging.Timer(LOGTAG, $"RemoteOperation{op.Operation}", $"RemoteOperation{op.Operation}")) switch (op) { case PutOperation putOp: - await Execute(putOp).ConfigureAwait(false); + await Execute(putOp, cancellationToken).ConfigureAwait(false); anyUploaded = true; return; case GetOperation getOp: - await Execute(getOp).ConfigureAwait(false); + await Execute(getOp, cancellationToken).ConfigureAwait(false); anyDownloaded = true; return; case DeleteOperation deleteOp: - await Execute(deleteOp).ConfigureAwait(false); + await Execute(deleteOp, cancellationToken).ConfigureAwait(false); return; case ListOperation listOp: - await Execute(listOp).ConfigureAwait(false); + await Execute(listOp, cancellationToken).ConfigureAwait(false); return; case QuotaInfoOperation quotaOp: - await Execute(quotaOp).ConfigureAwait(false); + await Execute(quotaOp, cancellationToken).ConfigureAwait(false); return; case WaitForEmptyOperation waitOp: waitOp.SetComplete(true); @@ -406,11 +513,12 @@ partial class BackendManager /// /// The return value type of the operation /// The operation to execute + /// The cancellation token /// An awaitable task - private async Task Execute(PendingOperation op) + private async Task Execute(PendingOperation op, CancellationToken cancellationToken) { using var backend = CreateBackend(); - using var token = CancellationTokenSource.CreateLinkedTokenSource(op.CancelToken, context.TaskReader.TransferToken); + using var token = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, op.CancelToken, context.TaskReader.TransferToken); try { diff --git a/Duplicati/Library/Main/Backend/BackendManager.ListOperation.cs b/Duplicati/Library/Main/Backend/BackendManager.ListOperation.cs index c3389ff31..de9647e86 100644 --- a/Duplicati/Library/Main/Backend/BackendManager.ListOperation.cs +++ b/Duplicati/Library/Main/Backend/BackendManager.ListOperation.cs @@ -33,11 +33,12 @@ partial class BackendManager /// The backend to list /// The cancellation token /// An awaitable task - public override Task> ExecuteAsync(IBackend backend, CancellationToken cancelToken) + public override async Task> ExecuteAsync(IBackend backend, CancellationToken cancelToken) { Context.Statwriter.SendEvent(BackendActionType.List, BackendEventType.Started, null, -1); - var r = backend.List().ToList(); + // TODO: Consider returning IAsyncEnumerable instead of List + var r = await backend.ListAsync(cancelToken).ToListAsync(cancelToken).ConfigureAwait(false); // TODO: Investigate better way to solve this so we do not use memory for large lists var sb = new StringBuilder(); @@ -57,7 +58,7 @@ partial class BackendManager Context.Statwriter.SendEvent(BackendActionType.List, BackendEventType.Completed, null, r.Count); - return Task.FromResult(r); + return r; } } } \ No newline at end of file diff --git a/Duplicati/Library/Main/Backend/BackendManager.PutOperation.cs b/Duplicati/Library/Main/Backend/BackendManager.PutOperation.cs index f9733c775..a63d5f07a 100644 --- a/Duplicati/Library/Main/Backend/BackendManager.PutOperation.cs +++ b/Duplicati/Library/Main/Backend/BackendManager.PutOperation.cs @@ -268,12 +268,10 @@ partial class BackendManager if (backend is IStreamingBackend streamingBackend && !Context.Options.DisableStreamingTransfers) { - using (var fs = System.IO.File.OpenRead(LocalFilename)) - using (var act = new StreamUtil.TimeoutObservingStream(fs) { ReadTimeout = backend is ITimeoutExemptBackend ? Timeout.Infinite : Context.Options.ReadWriteTimeout }) - using (var ts = new ThrottledStream(act, Context.Options.MaxUploadPrSecond, 0)) - using (var pgs = new ProgressReportingStream(ts, pg => Context.HandleProgress(ts, pg, RemoteFilename))) - using (var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(cancelToken, act.TimeoutToken)) - await streamingBackend.PutAsync(RemoteFilename, pgs, linkedToken.Token).ConfigureAwait(false); + using var fs = System.IO.File.OpenRead(LocalFilename); + using var ts = new ThrottledStream(fs, Context.Options.MaxUploadPrSecond, 0); + using var pgs = new ProgressReportingStream(ts, pg => Context.HandleProgress(ts, pg, RemoteFilename)); + await streamingBackend.PutAsync(RemoteFilename, pgs, cancelToken).ConfigureAwait(false); } else await backend.PutAsync(RemoteFilename, LocalFilename, cancelToken).ConfigureAwait(false); @@ -288,7 +286,7 @@ partial class BackendManager if (Context.Options.ListVerifyUploads) { - var f = backend.List().FirstOrDefault(n => n.Name.Equals(RemoteFilename, StringComparison.OrdinalIgnoreCase)); + var f = await backend.ListAsync(cancelToken).FirstOrDefaultAsync(n => n.Name.Equals(RemoteFilename, StringComparison.OrdinalIgnoreCase)).ConfigureAwait(false); if (f == null) throw new Exception(string.Format($"List verify failed, file was not found after upload: {RemoteFilename}")); else if (f.Size != Size && f.Size >= 0) diff --git a/Duplicati/Library/Main/Backend/BackendManager.cs b/Duplicati/Library/Main/Backend/BackendManager.cs index 2e8cedf1d..2c948edbe 100644 --- a/Duplicati/Library/Main/Backend/BackendManager.cs +++ b/Duplicati/Library/Main/Backend/BackendManager.cs @@ -25,10 +25,6 @@ internal partial class BackendManager : IBackendManager /// The log tag for the class /// private static readonly string LOGTAG = Logging.Log.LogTagFromType(); - /// - /// List of backend instances that are currently in use - /// - private readonly List backendPool = []; /// /// The channel for issuing and handling requests @@ -132,6 +128,18 @@ internal partial class BackendManager : IBackendManager return Convert.ToBase64String(hasher.ComputeHash(fs)); } + /// + /// Decrypts a file using the specified options + /// + /// The file to decrypt + /// The name of the file. Used for detecting encryption algorithm if not specified in options or if it differs from the options + /// The Duplicati options + /// The decrypted file + public TempFile DecryptFile(TempFile volume, string volume_name, Options options) + { + return GetOperation.DecryptFile(volume, volume_name, options); + } + /// /// Deletes a remote file /// @@ -159,7 +167,29 @@ internal partial class BackendManager : IBackendManager { var op = new GetOperation(remotename, size, context, cancelToken) { - Hash = hash + Hash = hash, + Decrypt = true + }; + await QueueTask(op).ConfigureAwait(false); + (var file, var _, var downloadSize) = await op.GetResult().ConfigureAwait(false); + LastReadSize = downloadSize; + return file; + } + + /// + /// Gets a file from the remote location without decrypting it + /// + /// The name of the remote file + /// The hash of the remote file, for verification + /// The size of the remote file, for verification + /// The cancellation token + /// A temporary file with the contents of the remote file + public async Task GetDirectAsync(string remotename, string hash, long size, CancellationToken cancelToken) + { + var op = new GetOperation(remotename, size, context, cancelToken) + { + Hash = hash, + Decrypt = false }; await QueueTask(op).ConfigureAwait(false); (var file, var _, var downloadSize) = await op.GetResult().ConfigureAwait(false); @@ -191,7 +221,8 @@ internal partial class BackendManager : IBackendManager { var op = new GetOperation(remotename, size, context, cancelToken) { - Hash = hash + Hash = hash, + Decrypt = true }; await QueueTask(op).ConfigureAwait(false); (var file, var downloadHash, var downloadSize) = await op.GetResult().ConfigureAwait(false); diff --git a/Duplicati/Library/Main/DatabaseLocator.cs b/Duplicati/Library/Main/CLIDatabaseLocator.cs similarity index 55% rename from Duplicati/Library/Main/DatabaseLocator.cs rename to Duplicati/Library/Main/CLIDatabaseLocator.cs index d4ddc57a0..0cd7f93df 100644 --- a/Duplicati/Library/Main/DatabaseLocator.cs +++ b/Duplicati/Library/Main/CLIDatabaseLocator.cs @@ -17,252 +17,242 @@ // 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.Linq; -using System.Collections.Generic; - -namespace Duplicati.Library.Main -{ - public static class DatabaseLocator - { - public class BackendEntry - { - public string Type; - public string Server; - public string Path; - public string Prefix; - public string Username; - //public string Passwordhash; - public int Port; - public string Databasepath; - public string ParameterFile; - } - - /// - /// The filename of the file with database configurations - /// - private const string CONFIG_FILE = "dbconfig.json"; - - /// - /// Finds a default storage folder, using the operating system specific locations. - /// The targetfilename is used to detect locations that are used in previous versions. - /// If the targetfilename is found in an old location, but not the current, the old location is used. - /// If running with DEBUG defined, the storage folder is placed in the same folder as the executable - /// - /// The filename to look for - /// The name of the application - /// The default storage folder - public static string GetDefaultStorageFolderWithDebugSupport(string targetfilename, string appName = "Duplicati") - { -#if DEBUG - return System.IO.Path.GetDirectoryName(typeof(DatabaseLocator).Assembly.Location) ?? string.Empty; -#else - return GetDefaultStorageFolder(targetfilename, appName); -#endif - } - - /// - /// Finds a default storage folder, using the operating system specific locations. - /// The targetfilename is used to detect locations that are used in previous versions. - /// If the targetfilename is found in an old location, but not the current, the old location is used. - /// - /// The filename to look for - /// The name of the application - /// The default storage folder - public static string GetDefaultStorageFolder(string targetfilename, string appName = "Duplicati") - { - //Normal mode uses the systems "(Local) Application Data" folder - // %LOCALAPPDATA% on Windows, ~/.config on Linux - var folder = System.IO.Path.Combine(System.Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), appName); - - if (OperatingSystem.IsWindows()) - { - // Special handling for Windows: - // - Older versions use %APPDATA% - // - but new versions use %LOCALAPPDATA% - var newlocation = System.IO.Path.Combine(System.Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), appName); - - var prevfile = System.IO.Path.Combine(folder, targetfilename); - var curfile = System.IO.Path.Combine(newlocation, targetfilename); - - // If the new file exists, we use that - // If the new file does not exist, and the old file exists we use the old - // Otherwise we use the new location - if (System.IO.File.Exists(curfile) || !System.IO.File.Exists(prevfile)) - folder = newlocation; - } - - if (OperatingSystem.IsMacOS()) - { - // Special handling for MacOS: - // - Older versions use ~/.config/ - // - but new versions use ~/Library/Application\ Support/ - var configfolder = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", appName); - - var prevfile = System.IO.Path.Combine(configfolder, targetfilename); - var curfile = System.IO.Path.Combine(folder, targetfilename); - - // If the old file exists, and not the new file, we use the old - // Otherwise we use the new location - if (!System.IO.File.Exists(curfile) && System.IO.File.Exists(prevfile)) - folder = configfolder; - } - - return folder; - } - - public static string GetDatabasePath(string backend, Options options, bool autoCreate = true, bool anyUsername = false) - { - if (options == null) - options = new Options(new Dictionary()); - - if (!string.IsNullOrEmpty(options.Dbpath)) - return options.Dbpath; - - var folder = GetDefaultStorageFolderWithDebugSupport(CONFIG_FILE); - - var file = System.IO.Path.Combine(folder, CONFIG_FILE); - List configs; - if (!System.IO.File.Exists(file)) - configs = new List(); - else - configs = Newtonsoft.Json.JsonConvert.DeserializeObject>(System.IO.File.ReadAllText(file, System.Text.Encoding.UTF8)); - - var uri = new Library.Utility.Uri(backend); - string server = uri.Host; - string path = uri.Path; - string type = uri.Scheme; - int port = uri.Port; - string username = uri.Username; - string prefix = options.Prefix; - - if (username == null || uri.Password == null) - { - var sopts = DynamicLoader.BackendLoader.GetSupportedCommands(backend); - var ropts = new Dictionary(options.RawOptions); - foreach (var k in uri.QueryParameters.AllKeys) - ropts[k] = uri.QueryParameters[k]; - - if (sopts != null) - { - foreach (var o in sopts) - { - if (username == null && o.Aliases != null && o.Aliases.Contains("auth-username", StringComparer.OrdinalIgnoreCase) && ropts.ContainsKey(o.Name)) - username = ropts[o.Name]; - } - - foreach (var o in sopts) - { - if (username == null && o.Name.Equals("auth-username", StringComparison.OrdinalIgnoreCase) && ropts.ContainsKey("auth-username")) - username = ropts["auth-username"]; - } - } - } - - //Now find the one that matches :) - var matches = (from n in configs - where - n.Type == type && - //n.Passwordhash == password && - n.Username == username && - n.Port == port && - n.Server == server && - n.Path == path && - n.Prefix == prefix - select n).ToList(); - - if (matches.Count > 1) - throw new Duplicati.Library.Interface.UserInformationException(string.Format("Multiple sources found for: {0}", backend), "MultipleLocalDatabaseSourcesFound"); - - // Re-select - if (matches.Count == 0 && anyUsername && string.IsNullOrEmpty(username)) - { - matches = (from n in configs - where - n.Type == type && - n.Port == port && - n.Server == server && - n.Path == path && - n.Prefix == prefix - select n).ToList(); - - if (matches.Count > 1) - throw new Duplicati.Library.Interface.UserInformationException(String.Format("Multiple sources found for \"{0}\", try supplying --{1}", backend, "auth-username"), "MultipleLocalDatabaseSourcesFound"); - } - - if (matches.Count == 0 && !autoCreate) - return null; - - if (matches.Count == 0) - { - var backupname = options.BackupName; - if (string.IsNullOrEmpty(backupname) || backupname == Options.DefaultBackupName) - backupname = GenerateRandomName(); - else - { - foreach (var c in System.IO.Path.GetInvalidFileNameChars()) - backupname = backupname.Replace(c.ToString(), ""); - } - - var newpath = System.IO.Path.Combine(folder, backupname + ".sqlite"); - int max_tries = 100; - while (System.IO.File.Exists(newpath) && max_tries-- > 0) - newpath = System.IO.Path.Combine(folder, GenerateRandomName()); - - if (System.IO.File.Exists(newpath)) - throw new Duplicati.Library.Interface.UserInformationException("Unable to find a unique name for the database, please use --dbpath", "CannotCreateRandomName"); - - //Create a new one, add it to the list, and save it - configs.Add(new BackendEntry() - { - Type = type, - Server = server, - Path = path, - Prefix = prefix, - Username = username, - //Passwordhash = password, - Port = port, - Databasepath = newpath, - ParameterFile = null - }); - - if (!System.IO.Directory.Exists(folder)) - System.IO.Directory.CreateDirectory(folder); - - var settings = new Newtonsoft.Json.JsonSerializerSettings(); - settings.Formatting = Newtonsoft.Json.Formatting.Indented; - System.IO.File.WriteAllText(file, Newtonsoft.Json.JsonConvert.SerializeObject(configs, settings), System.Text.Encoding.UTF8); - - return newpath; - } - else - { - return matches[0].Databasepath; - } - - } - - public static string GenerateRandomName() - { - var rnd = new Random(); - - System.Text.StringBuilder backupName = new System.Text.StringBuilder(); - for (var i = 0; i < 10; i++) - backupName.Append((char)rnd.Next('A', 'Z' + 1)); - - return backupName.ToString(); - } - - public static bool IsDatabasePathInUse(string path) - { - var file = System.IO.Path.Combine(GetDefaultStorageFolderWithDebugSupport(CONFIG_FILE), CONFIG_FILE); - if (!System.IO.File.Exists(file)) - return false; - - return Newtonsoft.Json.JsonConvert.DeserializeObject>(System.IO.File.ReadAllText(file, System.Text.Encoding.UTF8)).Any(x => string.Equals(path, x.Databasepath, Library.Utility.Utility.ClientFilenameStringComparison)); - } - } -} - +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Linq; +using System.Collections.Generic; +using Duplicati.Library.Common.IO; + +#nullable enable + +namespace Duplicati.Library.Main +{ + public static class CLIDatabaseLocator + { + /// + /// The log tag for this class + /// + public static readonly string LOGTAG = Logging.Log.LogTagFromType(typeof(CLIDatabaseLocator)); + + /// + /// The entry for a backend configuration, used for serialization + /// + public class BackendEntry + { + /// + /// The type of the backend + /// + public required string Type; + /// + /// The server/hostname of the backend + /// + public required string Server; + /// + /// The path of the backend + /// + public required string Path; + /// + /// The prefix of the backup + /// + public required string Prefix; + /// + /// The username of the backend + /// + public required string? Username; + /// + /// The port of the backend + /// + public required int Port; + /// + /// The path to the database + /// + public required string Databasepath; + /// + /// The path to the parameter file + /// + public required string? ParameterFile; + } + + /// + /// The filename of the file with database configurations + /// + private const string CONFIG_FILE = "dbconfig.json"; + + /// + /// Gets the database path for a given backend url, using the JSON configuration file lookup. + /// If is true, a new database is created if none is found. + /// Otherwise, null is returned if no database is found. + /// + /// The backend url + /// The options to use + /// If true, a new database is created if none is found + /// If true, any username is accepted + /// The database path or null + public static string? GetDatabasePathForCLI(string backend, Options? options, bool autoCreate = true, bool anyUsername = false) + { + options ??= new Options(new Dictionary()); + + if (!string.IsNullOrEmpty(options.Dbpath)) + return options.Dbpath; + + // Ideally, this should use DataFolderManager.DATAFOLDER, but we cannot due to backwards compatibility + var folder = AutoUpdater.DataFolderLocator.GetDefaultStorageFolder(CONFIG_FILE, true); + // Emit a warning if the database is stored in the Windows folder + if (Util.IsPathUnderWindowsFolder(folder)) + Logging.Log.WriteWarningMessage(LOGTAG, "DatabaseInWindowsFolder", null, "The database config is stored in the Windows folder, this is not recommended as it will be deleted on Windows upgrades."); + + + var file = System.IO.Path.Combine(folder, CONFIG_FILE); + List configs; + if (!System.IO.File.Exists(file)) + configs = new List(); + else + configs = Newtonsoft.Json.JsonConvert.DeserializeObject>(System.IO.File.ReadAllText(file, System.Text.Encoding.UTF8)) + ?? new List(); + + var uri = new Library.Utility.Uri(backend); + var server = uri.Host; + var path = uri.Path; + var type = uri.Scheme; + var port = uri.Port; + var username = uri.Username; + var prefix = options.Prefix; + + if (username == null || uri.Password == null) + { + var sopts = DynamicLoader.BackendLoader.GetSupportedCommands(backend); + var ropts = new Dictionary(options.RawOptions); + foreach (var k in uri.QueryParameters.AllKeys) + if (k != null) + ropts[k] = uri.QueryParameters[k]; + + if (sopts != null) + { + foreach (var o in sopts) + { + if (username == null && o.Aliases != null && o.Aliases.Contains("auth-username", StringComparer.OrdinalIgnoreCase) && ropts.ContainsKey(o.Name)) + username = ropts[o.Name]; + } + + foreach (var o in sopts) + { + if (username == null && o.Name.Equals("auth-username", StringComparison.OrdinalIgnoreCase) && ropts.ContainsKey("auth-username")) + username = ropts["auth-username"]; + } + } + } + + //Now find the one that matches :) + var matches = (from n in configs + where + n.Type == type && + n.Username == username && + n.Port == port && + n.Server == server && + n.Path == path && + n.Prefix == prefix + select n).ToList(); + + if (matches.Count > 1) + throw new Duplicati.Library.Interface.UserInformationException(string.Format("Multiple sources found for: {0}", backend), "MultipleLocalDatabaseSourcesFound"); + + // Re-select + if (matches.Count == 0 && anyUsername && string.IsNullOrEmpty(username)) + { + matches = (from n in configs + where + n.Type == type && + n.Port == port && + n.Server == server && + n.Path == path && + n.Prefix == prefix + select n).ToList(); + + if (matches.Count > 1) + throw new Duplicati.Library.Interface.UserInformationException(String.Format("Multiple sources found for \"{0}\", try supplying --{1}", backend, "auth-username"), "MultipleLocalDatabaseSourcesFound"); + } + + if (matches.Count == 0 && !autoCreate) + return null; + + if (matches.Count == 0) + { + var backupname = options.BackupName; + if (string.IsNullOrEmpty(backupname) || backupname == Options.DefaultBackupName) + backupname = GenerateRandomName(); + else + { + foreach (var c in System.IO.Path.GetInvalidFileNameChars()) + backupname = backupname.Replace(c.ToString(), ""); + } + + var newpath = System.IO.Path.Combine(folder, backupname + ".sqlite"); + int max_tries = 100; + while (System.IO.File.Exists(newpath) && max_tries-- > 0) + newpath = System.IO.Path.Combine(folder, GenerateRandomName()); + + if (System.IO.File.Exists(newpath)) + throw new Duplicati.Library.Interface.UserInformationException("Unable to find a unique name for the database, please use --dbpath", "CannotCreateRandomName"); + + //Create a new one, add it to the list, and save it + configs.Add(new BackendEntry() + { + Type = type, + Server = server, + Path = path, + Prefix = prefix, + Username = username, + //Passwordhash = password, + Port = port, + Databasepath = newpath, + ParameterFile = null + }); + + var settings = new Newtonsoft.Json.JsonSerializerSettings(); + settings.Formatting = Newtonsoft.Json.Formatting.Indented; + System.IO.File.WriteAllText(file, Newtonsoft.Json.JsonConvert.SerializeObject(configs, settings), System.Text.Encoding.UTF8); + + return newpath; + } + else + { + return matches[0].Databasepath; + } + } + + /// + /// Generates a random name for a database + /// + /// A random name + public static string GenerateRandomName() + { + var rnd = new Random(); + + var backupName = new System.Text.StringBuilder(); + for (var i = 0; i < 10; i++) + backupName.Append((char)rnd.Next('A', 'Z' + 1)); + + return backupName.ToString(); + } + + /// + /// Checks if a database path is in use by any backup. + /// If the file does not exist, it is assumed to be free. + /// If the file exists, it is checked if it is in use by any backup. + /// + /// The path to check + /// true if the path is in use, false otherwise + public static bool IsDatabasePathInUse(string path) + { + // Ideally, this should use DataFolderManager.DATAFOLDER, but we cannot due to backwards compatibility + var file = System.IO.Path.Combine(AutoUpdater.DataFolderLocator.GetDefaultStorageFolder(CONFIG_FILE, false), CONFIG_FILE); + if (!System.IO.File.Exists(file)) + return false; + + return (Newtonsoft.Json.JsonConvert.DeserializeObject>(System.IO.File.ReadAllText(file, System.Text.Encoding.UTF8)) ?? []) + .Any(x => string.Equals(path, x.Databasepath, Library.Utility.Utility.ClientFilenameStringComparison)); + } + } +} + diff --git a/Duplicati/Library/Main/Controller.cs b/Duplicati/Library/Main/Controller.cs index 4100f9cc3..ef4abc5db 100644 --- a/Duplicati/Library/Main/Controller.cs +++ b/Duplicati/Library/Main/Controller.cs @@ -114,110 +114,110 @@ namespace Duplicati.Library.Main SecretProvider = secretProvider; } - public Duplicati.Library.Interface.IBackupResults Backup(string[] inputsources, IFilter filter = null) + public IBackupResults Backup(string[] inputsources, IFilter filter = null) { - Library.UsageReporter.Reporter.Report("USE_BACKEND", new Library.Utility.Uri(m_backendUrl).Scheme); - Library.UsageReporter.Reporter.Report("USE_COMPRESSION", m_options.CompressionModule); - Library.UsageReporter.Reporter.Report("USE_ENCRYPTION", m_options.EncryptionModule); + UsageReporter.Reporter.Report("USE_BACKEND", new Library.Utility.Uri(m_backendUrl).Scheme); + UsageReporter.Reporter.Report("USE_COMPRESSION", m_options.CompressionModule); + UsageReporter.Reporter.Report("USE_ENCRYPTION", m_options.EncryptionModule); CheckAutoCompactInterval(); CheckAutoVacuumInterval(); - return RunAction(new BackupResults(), ref inputsources, ref filter, (result, backendManager) => + return RunAction(new BackupResults(), ref inputsources, ref filter, async (result, backendManager) => { - using (var h = new Operation.BackupHandler(m_backendUrl, m_options, result)) - { - h.RunAsync(ExpandInputSources(inputsources, filter), backendManager, filter).Await(); - } + using (var h = new Operation.BackupHandler(m_options, result)) + await h.RunAsync(ExpandInputSources(inputsources, filter), backendManager, filter) + .ConfigureAwait(false); - Library.UsageReporter.Reporter.Report("BACKUP_FILECOUNT", result.ExaminedFiles); - Library.UsageReporter.Reporter.Report("BACKUP_FILESIZE", result.SizeOfExaminedFiles); - Library.UsageReporter.Reporter.Report("BACKUP_DURATION", (long)result.Duration.TotalSeconds); + UsageReporter.Reporter.Report("BACKUP_FILECOUNT", result.ExaminedFiles); + UsageReporter.Reporter.Report("BACKUP_FILESIZE", result.SizeOfExaminedFiles); + UsageReporter.Reporter.Report("BACKUP_DURATION", (long)result.Duration.TotalSeconds); }); } - public Library.Interface.IRestoreResults Restore(string[] paths, Library.Utility.IFilter filter = null) + public IRestoreResults Restore(string[] paths, IFilter filter = null) { - return RunAction(new RestoreResults(), ref paths, ref filter, (result, backendManager) => + return RunAction(new RestoreResults(), ref paths, ref filter, async (result, backendManager) => { - new Operation.RestoreHandler(m_options, result).Run(paths, backendManager, filter); + await new Operation.RestoreHandler(m_options, result) + .RunAsync(paths, backendManager, filter) + .ConfigureAwait(false); - Library.UsageReporter.Reporter.Report("RESTORE_FILECOUNT", result.RestoredFiles); - Library.UsageReporter.Reporter.Report("RESTORE_FILESIZE", result.SizeOfRestoredFiles); - Library.UsageReporter.Reporter.Report("RESTORE_DURATION", (long)result.Duration.TotalSeconds); + UsageReporter.Reporter.Report("RESTORE_FILECOUNT", result.RestoredFiles); + UsageReporter.Reporter.Report("RESTORE_FILESIZE", result.SizeOfRestoredFiles); + UsageReporter.Reporter.Report("RESTORE_DURATION", (long)result.Duration.TotalSeconds); }); } - public Duplicati.Library.Interface.IRestoreControlFilesResults RestoreControlFiles(IEnumerable files = null, Library.Utility.IFilter filter = null) + public IRestoreControlFilesResults RestoreControlFiles(IEnumerable files = null, IFilter filter = null) { return RunAction(new RestoreControlFilesResults(), ref filter, (result, backendManager) => - { - new Operation.RestoreControlFilesHandler(m_options, result).Run(files, backendManager, filter); - }); + new Operation.RestoreControlFilesHandler(m_options, result) + .RunAsync(files, backendManager, filter) + ); } - public Duplicati.Library.Interface.IDeleteResults Delete() + public IDeleteResults Delete() { return RunAction(new DeleteResults(), (result, backendManager) => - { - new Operation.DeleteHandler(m_options, result).Run(backendManager); - }); + new Operation.DeleteHandler(m_options, result) + .RunAsync(backendManager) + ); } - public Duplicati.Library.Interface.IRepairResults Repair(Library.Utility.IFilter filter = null) + public IRepairResults Repair(IFilter filter = null) { return RunAction(new RepairResults(), ref filter, (result, backendManager) => - { - new Operation.RepairHandler(m_options, result).Run(backendManager, filter); - }); + new Operation.RepairHandler(m_options, result) + .RunAsync(backendManager, filter) + ); } - public Duplicati.Library.Interface.IListResults List() + public IListResults List() { return List(null, null); } - public Duplicati.Library.Interface.IListResults List(string filterstring) + public IListResults List(string filterstring) { return List(filterstring == null ? null : new string[] { filterstring }, null); } - public Duplicati.Library.Interface.IListResults List(IEnumerable filterstrings, Library.Utility.IFilter filter) + public IListResults List(IEnumerable filterstrings, IFilter filter) { return RunAction(new ListResults(), ref filter, (result, backendManager) => - { - new Operation.ListFilesHandler(m_options, result).Run(backendManager, filterstrings, filter).Await(); - }); + new Operation.ListFilesHandler(m_options, result) + .RunAsync(backendManager, filterstrings, filter) + ); } - public Duplicati.Library.Interface.IListResults ListControlFiles(IEnumerable filterstrings, Library.Utility.IFilter filter) + public IListResults ListControlFiles(IEnumerable filterstrings, IFilter filter) { return RunAction(new ListResults(), ref filter, (result, backendManager) => + new Operation.ListControlFilesHandler(m_options, result) + .RunAsync(backendManager, filterstrings, filter) + ); + } + + public IListRemoteResults ListRemote() + { + return RunAction(new ListRemoteResults(), async (result, backendManager) => { - new Operation.ListControlFilesHandler(m_options, result).Run(backendManager, filterstrings, filter); + using (var tf = File.Exists(m_options.Dbpath) ? null : new TempFile()) + using (var db = new LocalDatabase(((string)tf) ?? m_options.Dbpath, "list-remote", true)) + result.SetResult(await backendManager.ListAsync(CancellationToken.None).ConfigureAwait(false)); }); } - public Duplicati.Library.Interface.IListRemoteResults ListRemote() + public IListRemoteResults DeleteAllRemoteFiles() { - return RunAction(new ListRemoteResults(), (result, backendManager) => + return RunAction(new ListRemoteResults(), async (result, backendManager) => { - using (var tf = System.IO.File.Exists(m_options.Dbpath) ? null : new Library.Utility.TempFile()) - using (var db = new Database.LocalDatabase(((string)tf) ?? m_options.Dbpath, "list-remote", true)) - result.SetResult(backendManager.ListAsync(CancellationToken.None).Await()); - }); - } - - public Duplicati.Library.Interface.IListRemoteResults DeleteAllRemoteFiles() - { - return RunAction(new ListRemoteResults(), (result, backendManager) => - { - var cancelToken = CancellationToken.None; result.OperationProgressUpdater.UpdatePhase(OperationPhase.Delete_Listing); { // Only delete files that match the expected pattern and prefix - var list = backendManager.ListAsync(cancelToken).Await() + var list = (await backendManager.ListAsync(result.TaskControl.ProgressToken).ConfigureAwait(false)) .Select(x => Volumes.VolumeBase.ParseFilename(x)) .Where(x => x != null) .Where(x => x.Prefix == m_options.Prefix) @@ -227,12 +227,12 @@ namespace Duplicati.Library.Main // from the backend. Otherwise, we may accidentally delete non-Duplicati files, or // files from a different Duplicati configuration that points to the same backend location // and uses the same prefix (see issues #2678, #3845, and #4244). - if (System.IO.File.Exists(m_options.Dbpath)) + if (File.Exists(m_options.Dbpath)) { using (LocalDatabase db = new LocalDatabase(m_options.Dbpath, "list-remote", true)) { - IEnumerable dbRemoteVolumes = db.GetRemoteVolumes(); - HashSet dbRemoteFiles = new HashSet(dbRemoteVolumes.Select(x => x.Name)); + var dbRemoteVolumes = db.GetRemoteVolumes(); + var dbRemoteFiles = dbRemoteVolumes.Select(x => x.Name).ToHashSet(); list = list.Where(x => dbRemoteFiles.Contains(x.File.Name)).ToList(); } } @@ -243,7 +243,7 @@ namespace Duplicati.Library.Main { try { - backendManager.DeleteAsync(list[i].File.Name, list[i].File.Size, true, cancelToken).Await(); + await backendManager.DeleteAsync(list[i].File.Name, list[i].File.Size, true, result.TaskControl.ProgressToken).ConfigureAwait(false); } catch (Exception ex) { @@ -256,67 +256,67 @@ namespace Duplicati.Library.Main }); } - public Duplicati.Library.Interface.ICompactResults Compact() + public ICompactResults Compact() { CheckAutoVacuumInterval(); return RunAction(new CompactResults(), (result, backendManager) => - { - new Operation.CompactHandler(m_options, result).Run(backendManager).Await(); - }); + new Operation.CompactHandler(m_options, result) + .RunAsync(backendManager) + ); } - public Duplicati.Library.Interface.IRecreateDatabaseResults UpdateDatabaseWithVersions(Library.Utility.IFilter filter = null) + public IRecreateDatabaseResults UpdateDatabaseWithVersions(IFilter filter = null) { var filelistfilter = Operation.RestoreHandler.FilterNumberedFilelist(m_options.Time, m_options.Version, singleTimeMatch: true); - return RunAction(new RecreateDatabaseResults(), ref filter, (result, backendManager) => + return RunAction(new RecreateDatabaseResults(), ref filter, async (result, backendManager) => { using (var h = new Operation.RecreateDatabaseHandler(m_options, result)) - h.RunUpdate(backendManager, filter, filelistfilter, null); + await h.RunUpdateAsync(backendManager, filter, filelistfilter, null) + .ConfigureAwait(false); }); } - public Duplicati.Library.Interface.ICreateLogDatabaseResults CreateLogDatabase(string targetpath) + public ICreateLogDatabaseResults CreateLogDatabase(string targetpath) { var t = new string[] { targetpath }; return RunAction(new CreateLogDatabaseResults(), ref t, (result, backendManager) => - { - new Operation.CreateBugReportHandler(t[0], m_options, result).Run(); - }); + new Operation.CreateBugReportHandler(t[0], m_options, result).RunAsync() + ); } - public Duplicati.Library.Interface.IListChangesResults ListChanges(string baseVersion, string targetVersion, IEnumerable filterstrings = null, Library.Utility.IFilter filter = null, Action>> callback = null) + public IListChangesResults ListChanges(string baseVersion, string targetVersion, IEnumerable filterstrings = null, IFilter filter = null, Action>> callback = null) { var t = new string[] { baseVersion, targetVersion }; return RunAction(new ListChangesResults(), ref t, ref filter, (result, backendManager) => - { - new Operation.ListChangesHandler(m_options, result).Run(t[0], t[1], backendManager, filterstrings, filter, callback); - }); + new Operation.ListChangesHandler(m_options, result) + .RunAsync(t[0], t[1], backendManager, filterstrings, filter, callback) + ); } - public Duplicati.Library.Interface.IListAffectedResults ListAffected(List args, Action callback = null) + public IListAffectedResults ListAffected(List args, Action callback = null) { return RunAction(new ListAffectedResults(), (result, backendManager) => - { - new Operation.ListAffected(m_options, result).Run(args, callback); - }); + new Operation.ListAffected(m_options, result) + .RunAsync(args, callback) + ); } - public Duplicati.Library.Interface.ITestResults Test(long samples = 1) + public ITestResults Test(long samples = 1) { if (!m_options.RawOptions.ContainsKey("full-remote-verification")) m_options.RawOptions["full-remote-verification"] = "true"; return RunAction(new TestResults(), (result, backendManager) => - { - new Operation.TestHandler(m_options, result).Run(samples, backendManager); - }); + new Operation.TestHandler(m_options, result) + .RunAsync(samples, backendManager) + ); } - public Library.Interface.ITestFilterResults TestFilter(string[] paths, Library.Utility.IFilter filter = null) + public ITestFilterResults TestFilter(string[] paths, IFilter filter = null) { m_options.RawOptions["dry-run"] = "true"; m_options.RawOptions["dbpath"] = "INVALID!"; @@ -326,45 +326,44 @@ namespace Duplicati.Library.Main using (Logging.Log.StartScope(m_messageSink.WriteMessage, x => x.FilterTag.Contains(filtertag))) { return RunAction(new TestFilterResults(), ref paths, ref filter, (result, backendManager) => - { - new Operation.TestFilterHandler(m_options, result).RunAsync(ExpandInputSources(paths, filter), filter).Await(); - }); + new Operation.TestFilterHandler(m_options, result) + .RunAsync(ExpandInputSources(paths, filter), filter) + ); } } - public Library.Interface.ISystemInfoResults SystemInfo() + public ISystemInfoResults SystemInfo() { return RunAction(new SystemInfoResults(), (result, backendManager) => - { - Operation.SystemInfoHandler.Run(result); - }); + Operation.SystemInfoHandler.RunAsync(result) + ); } - public Library.Interface.IPurgeFilesResults PurgeFiles(Library.Utility.IFilter filter) + public IPurgeFilesResults PurgeFiles(IFilter filter) { return RunAction(new PurgeFilesResults(), (result, backendManager) => - { - new Operation.PurgeFilesHandler(m_options, result).Run(backendManager, filter); - }); + new Operation.PurgeFilesHandler(m_options, result) + .RunAsync(backendManager, filter) + ); } - public Library.Interface.IListBrokenFilesResults ListBrokenFiles(Library.Utility.IFilter filter, Func callbackhandler = null) + public IListBrokenFilesResults ListBrokenFiles(IFilter filter, Func callbackhandler = null) { return RunAction(new ListBrokenFilesResults(), (result, backendManager) => - { - new Operation.ListBrokenFilesHandler(m_options, result).Run(backendManager, filter, callbackhandler); - }); + new Operation.ListBrokenFilesHandler(m_options, result) + .RunAsync(backendManager, filter, callbackhandler) + ); } - public Library.Interface.IPurgeBrokenFilesResults PurgeBrokenFiles(Library.Utility.IFilter filter) + public IPurgeBrokenFilesResults PurgeBrokenFiles(IFilter filter) { return RunAction(new PurgeBrokenFilesResults(), (result, backendManager) => - { - new Operation.PurgeBrokenFilesHandler(m_options, result).Run(backendManager, filter); - }); + new Operation.PurgeBrokenFilesHandler(m_options, result) + .RunAsync(backendManager, filter) + ); } - public Library.Interface.ISendMailResults SendMail() + public ISendMailResults SendMail() { m_options.RawOptions["send-mail-level"] = "all"; m_options.RawOptions["send-mail-any-operation"] = "true"; @@ -387,22 +386,24 @@ namespace Duplicati.Library.Main using (Logging.Log.StartScope(m_messageSink.WriteMessage, x => x.FilterTag.Contains(filtertag))) { return RunAction(new SendMailResults(), (result, backendManager) => - { - result.Lines = new string[0]; - System.Threading.Thread.Sleep(5); - }); + Task.Run(() => + { + result.Lines = new string[0]; + System.Threading.Thread.Sleep(5); + }) + ); } } - public Library.Interface.IVacuumResults Vacuum() + public IVacuumResults Vacuum() { return RunAction(new VacuumResults(), (result, backendManager) => - { - new Operation.VacuumHandler(m_options, result).Run(); - }); + new Operation.VacuumHandler(m_options, result) + .RunAsync() + ); } - private T RunAction(T result, Action method) + private T RunAction(T result, Func method) where T : ISetCommonOptions, ITaskControlProvider, Logging.ILogDestination, IBasicResults, IBackendWriterProvider { var tmp = new string[0]; @@ -410,21 +411,21 @@ namespace Duplicati.Library.Main return RunAction(result, ref tmp, ref tempfilter, method); } - private T RunAction(T result, ref string[] paths, Action method) + private T RunAction(T result, ref string[] paths, Func method) where T : ISetCommonOptions, ITaskControlProvider, Logging.ILogDestination, IBasicResults, IBackendWriterProvider { IFilter tempfilter = null; return RunAction(result, ref paths, ref tempfilter, method); } - private T RunAction(T result, ref IFilter filter, Action method) + private T RunAction(T result, ref IFilter filter, Func method) where T : ISetCommonOptions, ITaskControlProvider, Logging.ILogDestination, IBasicResults, IBackendWriterProvider { var tmp = new string[0]; return RunAction(result, ref tmp, ref filter, method); } - private T RunAction(T result, ref string[] paths, ref IFilter filter, Action method) + private T RunAction(T result, ref string[] paths, ref IFilter filter, Func method) where T : ISetCommonOptions, ITaskControlProvider, Logging.ILogDestination, IBasicResults, IBackendWriterProvider { OnOperationStarted?.Invoke(result); @@ -449,13 +450,17 @@ namespace Duplicati.Library.Main using (m_options.ConcurrencyMaxThreads <= 0 ? null : new CoCoL.CappedThreadedThreadPool(m_options.ConcurrencyMaxThreads)) using (var backend = new Backend.BackendManager(m_backendUrl, m_options, result.BackendWriter, result.TaskControl)) { - method(result, backend); + method(result, backend).Await(); // TODO: Should also have a single shared database connection for all operations // The transactions should be managed inside the connection, and not passed around // This would allow us to pass the database instance to the backend manager // And safeguard against remote operations not being logged in the database + + // This would also allow us to control the unclean shutdown flag, + // by toggling this on start and completion of transfers in the manager, + // instead of relying on the operations to correctly toggle the flag if (File.Exists(m_options.Dbpath)) { using (var db = new LocalDatabase(m_options.Dbpath, result.MainOperation.ToString(), true)) @@ -485,7 +490,7 @@ namespace Duplicati.Library.Main { resultSetter.EndTime = DateTime.UtcNow; - if (ex is Library.Interface.OperationAbortException oae) + if (ex is OperationAbortException oae) { // Log this as a normal operation, as the script raising the exception, // has already populated either warning or log messages as required @@ -559,36 +564,16 @@ namespace Duplicati.Library.Main } } - /// - /// Attempts to get the locale, but delays linking to the calls as they are missing in some environments - /// - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - private static void DoGetLocale(out System.Globalization.CultureInfo locale, out System.Globalization.CultureInfo uiLocale) - { - locale = System.Globalization.CultureInfo.DefaultThreadCurrentCulture; - uiLocale = System.Globalization.CultureInfo.DefaultThreadCurrentUICulture; - } - - /// - /// Attempts to set the locale, but delays linking to the calls as they are missing in some environments - /// - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - private static void DoSetLocale(System.Globalization.CultureInfo locale, System.Globalization.CultureInfo uiLocale) - { - System.Globalization.CultureInfo.DefaultThreadCurrentCulture = locale; - System.Globalization.CultureInfo.DefaultThreadCurrentUICulture = uiLocale; - } - private void OperationComplete(IBasicResults result, Exception exception) { if (m_options != null && m_options.LoadedModules != null) { - foreach (KeyValuePair mx in m_options.LoadedModules) + foreach (KeyValuePair mx in m_options.LoadedModules) if (mx.Key && mx.Value is IGenericCallbackModule module) try { module.OnFinish(result, exception); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, $"OnFinishError{mx.Key}", ex, "OnFinish callback {0} failed: {1}", mx.Key, ex.Message); } - foreach (KeyValuePair mx in m_options.LoadedModules) + foreach (KeyValuePair mx in m_options.LoadedModules) if (mx.Key && mx.Value is IDisposable disposable) try { disposable.Dispose(); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, $"DisposeError{mx.Key}", ex, "Dispose for {0} failed: {1}", mx.Key, ex.Message); } @@ -630,11 +615,11 @@ namespace Duplicati.Library.Main //Load all generic modules m_options.LoadedModules.Clear(); - foreach (Library.Interface.IGenericModule m in DynamicLoader.GenericLoader.Modules) - m_options.LoadedModules.Add(new KeyValuePair(!m_options.DisableModules.Contains(m.Key, StringComparer.OrdinalIgnoreCase) && (m.LoadAsDefault || m_options.EnableModules.Contains(m.Key, StringComparer.OrdinalIgnoreCase)), m)); + foreach (IGenericModule m in DynamicLoader.GenericLoader.Modules) + m_options.LoadedModules.Add(new KeyValuePair(!m_options.DisableModules.Contains(m.Key, StringComparer.OrdinalIgnoreCase) && (m.LoadAsDefault || m_options.EnableModules.Contains(m.Key, StringComparer.OrdinalIgnoreCase)), m)); // Make the filter read-n-write able in the generic modules - var pristinefilter = string.Join(System.IO.Path.PathSeparator.ToString(), FilterExpression.Serialize(filter)); + var pristinefilter = string.Join(Path.PathSeparator.ToString(), FilterExpression.Serialize(filter)); m_options.RawOptions["filter"] = pristinefilter; // Store the URL connection options separately, as these should only be visible to modules implementing IConnectionModule @@ -658,7 +643,7 @@ namespace Duplicati.Library.Main foreach (var mx in LoadedModules) if (mx.Key) { - if (mx.Value is Library.Interface.IConnectionModule) + if (mx.Value is IConnectionModule) mx.Value.Configure(conopts); else mx.Value.Configure(m_options.RawOptions); @@ -681,15 +666,15 @@ namespace Duplicati.Library.Main // If the filters were changed by a module, read them back in if (pristinefilter != m_options.RawOptions["filter"]) { - filter = FilterExpression.Deserialize(m_options.RawOptions["filter"].Split(new string[] { System.IO.Path.PathSeparator.ToString() }, StringSplitOptions.RemoveEmptyEntries)); + filter = FilterExpression.Deserialize(m_options.RawOptions["filter"].Split(new string[] { Path.PathSeparator.ToString() }, StringSplitOptions.RemoveEmptyEntries)); } m_options.RawOptions.Remove("filter"); // "--filter" is not a supported command line option if (!string.IsNullOrEmpty(m_options.Logfile)) { - var path = System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(m_options.Logfile)); - if (!System.IO.Directory.Exists(path)) - System.IO.Directory.CreateDirectory(path); + var path = Path.GetDirectoryName(Path.GetFullPath(m_options.Logfile)); + if (!Directory.Exists(path)) + Directory.CreateDirectory(path); m_logTarget.AddTarget( new Library.Logging.StreamLogDestination(m_options.Logfile), @@ -718,7 +703,7 @@ namespace Duplicati.Library.Main } if (string.IsNullOrEmpty(m_options.Dbpath)) - m_options.Dbpath = DatabaseLocator.GetDatabasePath(m_backendUrl, m_options); + m_options.Dbpath = CLIDatabaseLocator.GetDatabasePathForCLI(m_backendUrl, m_options); ValidateOptions(); } @@ -765,6 +750,9 @@ namespace Duplicati.Library.Main if (!string.IsNullOrWhiteSpace(m_options.Prefix) && m_options.Prefix.Contains("-")) throw new Interface.UserInformationException("The prefix cannot contain hyphens (-)", "PrefixCannotContainHyphens"); + if (m_options.VolumeSize < m_options.Blocksize * 2) + throw new Interface.UserInformationException("The volume size must be at least twice the block size", "VolumeSizeTooSmall"); + //Check validity of retention-policy option value try { @@ -783,25 +771,25 @@ namespace Duplicati.Library.Main } //Keep a list of all supplied options - Dictionary ropts = new Dictionary(m_options.RawOptions); + var ropts = new Dictionary(m_options.RawOptions); //Keep a list of all supported options - Dictionary supportedOptions = new Dictionary(); + var supportedOptions = new Dictionary(); //There are a few internal options that are not accessible from outside, and thus not listed foreach (string s in Options.InternalOptions) supportedOptions[s] = null; //Figure out what module options are supported in the current setup - List moduleOptions = new List(); - Dictionary disabledModuleOptions = new Dictionary(); + var moduleOptions = new List(); + var disabledModuleOptions = new Dictionary(); - foreach (KeyValuePair m in m_options.LoadedModules) + foreach (var m in m_options.LoadedModules) if (m.Value.SupportedCommands != null) if (m.Key) moduleOptions.AddRange(m.Value.SupportedCommands); else - foreach (Library.Interface.ICommandLineArgument c in m.Value.SupportedCommands) + foreach (ICommandLineArgument c in m.Value.SupportedCommands) { disabledModuleOptions[c.Name] = m.Value.DisplayName + " (" + m.Value.Key + ")"; @@ -825,7 +813,7 @@ namespace Duplicati.Library.Main DynamicLoader.CompressionLoader.GetSupportedCommands(m_options.CompressionModule) }) { if (l != null) - foreach (Library.Interface.ICommandLineArgument a in l) + foreach (ICommandLineArgument a in l) { if (supportedOptions.ContainsKey(a.Name) && !Options.KnownDuplicates.Contains(a.Name, StringComparer.OrdinalIgnoreCase)) Logging.Log.WriteWarningMessage(LOGTAG, "DuplicateOption", null, Strings.Controller.DuplicateOptionNameWarning(a.Name)); @@ -863,7 +851,7 @@ namespace Duplicati.Library.Main } //Now look for options that were supplied but not supported - foreach (string s in ropts.Keys) + foreach (var s in ropts.Keys) if (!supportedOptions.ContainsKey(s)) if (disabledModuleOptions.ContainsKey(s)) Logging.Log.WriteWarningMessage(LOGTAG, "UnsupportedDisabledModule", null, Strings.Controller.UnsupportedOptionDisabledModuleWarning(s, disabledModuleOptions[s]), null); @@ -871,10 +859,9 @@ namespace Duplicati.Library.Main Logging.Log.WriteWarningMessage(LOGTAG, "UnsupportedOption", null, Strings.Controller.UnsupportedOptionWarning(s), null); //Look at the value supplied for each argument and see if is valid according to its type - foreach (string s in ropts.Keys) + foreach (var s in ropts.Keys) { - Library.Interface.ICommandLineArgument arg; - if (supportedOptions.TryGetValue(s, out arg) && arg != null) + if (supportedOptions.TryGetValue(s, out var arg) && arg != null) { string validationMessage = ValidateOptionValue(arg, s, ropts[s]); if (validationMessage != null) @@ -886,6 +873,10 @@ namespace Duplicati.Library.Main if (string.Equals(new Library.Utility.Uri(m_backendUrl).Scheme, "tardigrade", StringComparison.OrdinalIgnoreCase)) Logging.Log.WriteWarningMessage(LOGTAG, "TardigradeRename", null, "The Tardigrade-backend got renamed to Storj DCS - please migrate your backups to the new configuration by changing the destination storage type to Storj DCS."); + //Inform the user about the unmaintained Mega support library + if (string.Equals(new Library.Utility.Uri(m_backendUrl).Scheme, "mega", StringComparison.OrdinalIgnoreCase)) + Logging.Log.WriteWarningMessage(LOGTAG, "MegaUnmaintained", null, "The Mega support library is currently unmaintained and may not work as expected. Mega has not published an official API so it may break at any moment. Please consider migrating to another backend."); + //TODO: Based on the action, see if all options are relevant } @@ -897,11 +888,11 @@ namespace Duplicati.Library.Main private string[] ExpandInputSources(string[] inputsources, IFilter filter) { if (inputsources == null || inputsources.Length == 0) - throw new Duplicati.Library.Interface.UserInformationException(Strings.Controller.NoSourceFoldersError, "NoSourceFolders"); + throw new UserInformationException(Strings.Controller.NoSourceFoldersError, "NoSourceFolders"); var sources = new List(inputsources.Length); - System.IO.DriveInfo[] drives = null; + DriveInfo[] drives = null; //Make sure they all have the same format and exist foreach (var inputsource in inputsources) @@ -912,11 +903,11 @@ namespace Duplicati.Library.Main { // *: drive paths are only supported on Windows clients // Lazily load the drive info - drives = drives ?? System.IO.DriveInfo.GetDrives(); + drives = drives ?? DriveInfo.GetDrives(); // Replace the drive letter with each available drive string sourcePath = inputsource.Substring(1); - foreach (System.IO.DriveInfo drive in drives) + foreach (DriveInfo drive in drives) { string expandedSource = drive.Name[0] + sourcePath; Logging.Log.WriteVerboseMessage(LOGTAG, "AddingSourcePathFromWildcard", @"Adding source path ""{0}"" due to wildcard source path ""{1}""", expandedSource, inputsource); @@ -943,14 +934,14 @@ namespace Duplicati.Library.Main { // If we aren't allow to have missing sources, throw an exception indicating we couldn't find a drive where this volume is mounted if (!m_options.AllowMissingSource) - throw new Duplicati.Library.Interface.UserInformationException(Strings.Controller.SourceVolumeNameNotFoundError(inputsource, volumeGuid), "MissingSourceFolder"); + throw new UserInformationException(Strings.Controller.SourceVolumeNameNotFoundError(inputsource, volumeGuid), "MissingSourceFolder"); } } else { // If we aren't allow to have missing sources, throw an exception indicating we couldn't find this volume if (!m_options.AllowMissingSource) - throw new Duplicati.Library.Interface.UserInformationException(Strings.Controller.SourceVolumeNameInvalidError(inputsource), "SourceVolumeNameInvalid"); + throw new UserInformationException(Strings.Controller.SourceVolumeNameInvalidError(inputsource), "SourceVolumeNameInvalid"); } } else @@ -965,17 +956,30 @@ namespace Duplicati.Library.Main string source; try { + // Check if this is a mounted path + if (expandedSource.StartsWith("@")) + { + // TODO: If the remote source fails to load, + // this will be an enumeration warning, but will result + // in the backup being recorded without files from the source + // Eventually, this could lead to retention deletion, + // causing the last backup with the data from the source to be deleted + foundAnyPaths = true; + sources.Add(expandedSource); + continue; + } + // TODO: This expands "C:" to CWD, but not C:\ - source = System.IO.Path.GetFullPath(expandedSource); + source = Path.GetFullPath(expandedSource); } catch (Exception ex) { // Note that we use the original source (with the *) in the error - throw new Duplicati.Library.Interface.UserInformationException(Strings.Controller.InvalidPathError(expandedSource, ex.Message), "InputSourceInvalid", ex); + throw new UserInformationException(Strings.Controller.InvalidPathError(expandedSource, ex.Message), "InputSourceInvalid", ex); } - var fi = new System.IO.FileInfo(source); - var di = new System.IO.DirectoryInfo(source); + var fi = new FileInfo(source); + var di = new DirectoryInfo(source); if (fi.Exists || di.Exists) { foundAnyPaths = true; @@ -1007,16 +1011,16 @@ namespace Duplicati.Library.Main { if (unauthorized) { - throw new System.IO.IOException(Strings.Controller.SourceUnauthorizedError(inputsource)); + throw new IOException(Strings.Controller.SourceUnauthorizedError(inputsource)); } - throw new System.IO.IOException(Strings.Controller.SourceIsMissingError(inputsource)); + throw new IOException(Strings.Controller.SourceIsMissingError(inputsource)); } } //Sanity check for duplicate files/folders ISet pathDuplicates; - sources = Library.Utility.Utility.GetUniqueItems(sources, Library.Utility.Utility.ClientFilenameStringComparer, out pathDuplicates).OrderBy(a => a).ToList(); + sources = Library.Utility.Utility.GetUniqueItems(sources, Library.Utility.Utility.ClientFilenameStringComparer, out pathDuplicates).ToList(); foreach (var pathDuplicate in pathDuplicates) Logging.Log.WriteVerboseMessage(LOGTAG, "RemoveDuplicateSource", "Removing duplicate source: {0}", pathDuplicate); @@ -1059,9 +1063,9 @@ namespace Duplicati.Library.Main /// The name of the option to validate /// The value to check /// Null if no errors are found, an error message otherwise - private static string ValidateOptionValue(Library.Interface.ICommandLineArgument arg, string optionname, string value) + private static string ValidateOptionValue(ICommandLineArgument arg, string optionname, string value) { - if (arg.Type == Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Enumeration) + if (arg.Type == CommandLineArgument.ArgumentType.Enumeration) { bool found = false; foreach (string v in arg.ValidValues ?? new string[0]) @@ -1075,7 +1079,7 @@ namespace Duplicati.Library.Main return Strings.Controller.UnsupportedEnumerationValue(optionname, value, arg.ValidValues ?? new string[0]); } - else if (arg.Type == Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Flags) + else if (arg.Type == CommandLineArgument.ArgumentType.Flags) { bool validatedAllFlags = false; var flags = (value ?? string.Empty).ToLowerInvariant().Split(new[] { "," }, StringSplitOptions.None).Select(flag => flag.Trim()).Distinct(); @@ -1097,23 +1101,23 @@ namespace Duplicati.Library.Main return Strings.Controller.UnsupportedFlagsValue(optionname, value, validFlags); } } - else if (arg.Type == Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean) + else if (arg.Type == CommandLineArgument.ArgumentType.Boolean) { if (!string.IsNullOrEmpty(value) && Library.Utility.Utility.ParseBool(value, true) != Library.Utility.Utility.ParseBool(value, false)) return Strings.Controller.UnsupportedBooleanValue(optionname, value); } - else if (arg.Type == Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Integer) + else if (arg.Type == CommandLineArgument.ArgumentType.Integer) { if (!long.TryParse(value, out _)) return Strings.Controller.UnsupportedIntegerValue(optionname, value); } - else if (arg.Type == Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path) + else if (arg.Type == CommandLineArgument.ArgumentType.Path) { - foreach (string p in value.Split(System.IO.Path.DirectorySeparatorChar)) - if (p.IndexOfAny(System.IO.Path.GetInvalidPathChars()) >= 0) + foreach (string p in value.Split(Path.DirectorySeparatorChar)) + if (p.IndexOfAny(Path.GetInvalidPathChars()) >= 0) return Strings.Controller.UnsupportedPathValue(optionname, p); } - else if (arg.Type == Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Size) + else if (arg.Type == CommandLineArgument.ArgumentType.Size) { try { @@ -1127,7 +1131,7 @@ namespace Duplicati.Library.Main if (!string.IsNullOrWhiteSpace(value) && char.IsDigit(value.Last())) return Strings.Controller.NonQualifiedSizeValue(optionname, value); } - else if (arg.Type == Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Timespan) + else if (arg.Type == CommandLineArgument.ArgumentType.Timespan) { try { diff --git a/Duplicati/Library/Main/Database/ExtensionMethods.cs b/Duplicati/Library/Main/Database/ExtensionMethods.cs index 8a850cb7c..7512f9412 100644 --- a/Duplicati/Library/Main/Database/ExtensionMethods.cs +++ b/Duplicati/Library/Main/Database/ExtensionMethods.cs @@ -20,6 +20,7 @@ // DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; +using System.Data; using System.Linq; #nullable enable @@ -27,7 +28,7 @@ using System.Linq; namespace Duplicati.Library.Main.Database; /// -/// Extension method for +/// Extension method for /// public static class ExtensionMethods { @@ -44,7 +45,7 @@ public static class ExtensionMethods /// The parameters to add and set /// The command with the parameters added and set public static T AddAndSetParameters(this T cmd, params object?[]? parameters) - where T : System.Data.IDbCommand + where T : IDbCommand { if (parameters != null) for (var i = 0; i < parameters.Length; i++) @@ -65,7 +66,7 @@ public static class ExtensionMethods /// The number of parameters to add /// The command with the parameters added public static T AddParameters(this T self, int count) - where T : System.Data.IDbCommand + where T : IDbCommand { for (var i = 0; i < count; i++) self.Parameters.Add(self.CreateParameter()); @@ -80,7 +81,7 @@ public static class ExtensionMethods /// The command to add the parameter to /// The command with the parameter added public static T AddParameter(this T self) - where T : System.Data.IDbCommand + where T : IDbCommand { self.Parameters.Add(self.CreateParameter()); return self; @@ -95,7 +96,7 @@ public static class ExtensionMethods /// The optional name of the parameter /// The command with the parameter added public static T AddParameter(this T self, object? value, string? name = null) - where T : System.Data.IDbCommand + where T : IDbCommand { var p = self.CreateParameter(); p.Value = value; @@ -113,10 +114,10 @@ public static class ExtensionMethods /// The values to set the parameters to /// The command with the parameter values set public static T SetParameterValues(this T self, params object?[] values) - where T : System.Data.IDbCommand + where T : IDbCommand { for (var i = 0; i < values.Length; i++) - ((System.Data.IDataParameter)self.Parameters[i]!).Value = values[i]; + ((IDataParameter)self.Parameters[i]!).Value = values[i]; return self; } @@ -129,9 +130,9 @@ public static class ExtensionMethods /// The value to set the parameter to /// The command with the parameter value set public static T SetParameterValue(this T self, int index, object? value) - where T : System.Data.IDbCommand + where T : IDbCommand { - ((System.Data.IDataParameter)self.Parameters[index]!).Value = value; + ((IDataParameter)self.Parameters[index]!).Value = value; return self; } @@ -140,11 +141,11 @@ public static class ExtensionMethods /// The command to get the printable command text for /// The printable command text - public static string GetPrintableCommandText(this System.Data.IDbCommand self) + public static string GetPrintableCommandText(this IDbCommand self) { var txt = self.CommandText; - foreach (var p in self.Parameters.Cast()) + foreach (var p in self.Parameters.Cast()) { var ix = txt.IndexOf('?'); if (ix >= 0) @@ -170,7 +171,7 @@ public static class ExtensionMethods /// The command instance to execute on /// Whether to write a log entry /// The number of rows affected - public static int ExecuteNonQuery(this System.Data.IDbCommand self, bool writeLog) + public static int ExecuteNonQuery(this IDbCommand self, bool writeLog) { return ExecuteNonQuery(self, writeLog, null, null); } @@ -182,7 +183,7 @@ public static class ExtensionMethods /// The command string to execute /// The values to use as parameters. The parameters must already be added. /// The number of rows affected - public static int ExecuteNonQuery(this System.Data.IDbCommand self, string cmd, params object?[]? values) + public static int ExecuteNonQuery(this IDbCommand self, string cmd, params object?[]? values) { return ExecuteNonQuery(self, true, cmd, values); } @@ -195,7 +196,7 @@ public static class ExtensionMethods /// The command string to execute /// The values to use as parameters. The parameters must already be added. /// The number of rows affected - public static int ExecuteNonQuery(this System.Data.IDbCommand self, bool writeLog, string? cmd, params object?[]? values) + public static int ExecuteNonQuery(this IDbCommand self, bool writeLog, string? cmd, params object?[]? values) { if (cmd != null) self.CommandText = cmd; @@ -218,7 +219,7 @@ public static class ExtensionMethods /// The command string to execute /// The values to use as parameters. The parameters must already be added. /// The scalar value of the first row - public static object? ExecuteScalar(this System.Data.IDbCommand self, string cmd, params object[] values) + public static object? ExecuteScalar(this IDbCommand self, string cmd, params object[] values) { return ExecuteScalar(self, true, cmd, values); } @@ -231,7 +232,7 @@ public static class ExtensionMethods /// The command string to execute /// The values to use as parameters. The parameters must already be added. /// The scalar value of the first row - public static object? ExecuteScalar(this System.Data.IDbCommand self, bool writeLog, string cmd, params object[] values) + public static object? ExecuteScalar(this IDbCommand self, bool writeLog, string cmd, params object[] values) { if (cmd != null) self.CommandText = cmd; @@ -254,7 +255,7 @@ public static class ExtensionMethods /// Whether to write a log entry /// The default value to return if no value is found /// The scalar int64 value of the first row as a string - public static long ExecuteScalarInt64(this System.Data.IDbCommand self, bool writeLog, long defaultvalue = -1) + public static long ExecuteScalarInt64(this IDbCommand self, bool writeLog, long defaultvalue = -1) { return ExecuteScalarInt64(self, writeLog, null, defaultvalue); } @@ -265,7 +266,7 @@ public static class ExtensionMethods /// The command instance to execute on /// The default value to return if no value is found /// The scalar int64 value of the first row as a string - public static long ExecuteScalarInt64(this System.Data.IDbCommand self, long defaultvalue = -1) + public static long ExecuteScalarInt64(this IDbCommand self, long defaultvalue = -1) { return ExecuteScalarInt64(self, true, null, defaultvalue); } @@ -278,7 +279,7 @@ public static class ExtensionMethods /// The command string to execute /// The default value to return if no value is found /// The scalar int64 value of the first row as a string - public static long ExecuteScalarInt64(this System.Data.IDbCommand self, bool writeLog, string? cmd, long defaultvalue = -1) + public static long ExecuteScalarInt64(this IDbCommand self, bool writeLog, string? cmd, long defaultvalue = -1) { return ExecuteScalarInt64(self, writeLog, cmd, defaultvalue, null); } @@ -290,7 +291,7 @@ public static class ExtensionMethods /// The command string to execute /// The default value to return if no value is found /// The scalar int64 value of the first row as a string - public static long ExecuteScalarInt64(this System.Data.IDbCommand self, string? cmd, long defaultvalue = -1) + public static long ExecuteScalarInt64(this IDbCommand self, string? cmd, long defaultvalue = -1) { return ExecuteScalarInt64(self, true, cmd, defaultvalue, null); } @@ -303,7 +304,7 @@ public static class ExtensionMethods /// The default value to return if no value is found /// The values to use as parameters. The parameters must already be added. /// The scalar int64 value of the first row as a string - public static long ExecuteScalarInt64(this System.Data.IDbCommand self, string? cmd, long defaultvalue, params object?[]? values) + public static long ExecuteScalarInt64(this IDbCommand self, string? cmd, long defaultvalue, params object?[]? values) { return ExecuteScalarInt64(self, true, cmd, defaultvalue, values); } @@ -317,7 +318,7 @@ public static class ExtensionMethods /// The default value to return if no value is found /// The values to use as parameters. The parameters must already be added. /// The scalar int64 value of the first row as a string - public static long ExecuteScalarInt64(this System.Data.IDbCommand self, bool writeLog, string? cmd, long defaultvalue, params object?[]? values) + public static long ExecuteScalarInt64(this IDbCommand self, bool writeLog, string? cmd, long defaultvalue, params object?[]? values) { if (cmd != null) self.CommandText = cmd; @@ -343,8 +344,8 @@ public static class ExtensionMethods /// The command instance to execute on /// The command string to execute /// The values to use as parameters. The parameters must already be added. - /// A instance - public static System.Data.IDataReader ExecuteReader(this System.Data.IDbCommand self, string cmd, params object[] values) + /// A instance + public static IDataReader ExecuteReader(this IDbCommand self, string cmd, params object[] values) { return ExecuteReader(self, true, cmd, values); } @@ -356,8 +357,8 @@ public static class ExtensionMethods /// Whether to write a log entry /// The command string to execute /// The values to use as parameters. The parameters must already be added. - /// A instance - public static System.Data.IDataReader ExecuteReader(this System.Data.IDbCommand self, bool writeLog, string cmd, params object[] values) + /// A instance + public static IDataReader ExecuteReader(this IDbCommand self, bool writeLog, string cmd, params object[] values) { if (cmd != null) self.CommandText = cmd; @@ -373,18 +374,6 @@ public static class ExtensionMethods return self.ExecuteReader(); } - /// - /// Executes the command and returns an enumerable of data readers. - /// - /// The database command to execute. - /// An enumerable of data readers. - public static IEnumerable ExecuteReaderEnumerable(this System.Data.IDbCommand self) - { - using var rd = self.ExecuteReader(); - while (rd.Read()) - yield return rd; - } - /// /// Executes the given command string `cmd` on the given database command `self` with the given values `values` and returns an enumerable of data readers. /// @@ -392,7 +381,7 @@ public static class ExtensionMethods /// The command string to execute. /// The values that the command string should be parameterized with. /// - public static IEnumerable ExecuteReaderEnumerable(this System.Data.IDbCommand self, string cmd, params object[] values) + public static IEnumerable ExecuteReaderEnumerable(this IDbCommand self, string cmd, params object[] values) { using var rd = ExecuteReader(self, cmd, values); while (rd.Read()) @@ -405,7 +394,7 @@ public static class ExtensionMethods /// The data reader to convert the value from. /// The index of the value to convert. /// The value at the given index as a string. - public static string? ConvertValueToString(this System.Data.IDataReader reader, int index) + public static string? ConvertValueToString(this IDataReader reader, int index) { var v = reader.GetValue(index); if (v == null || v == DBNull.Value) @@ -421,7 +410,7 @@ public static class ExtensionMethods /// The index of the value to convert. /// The default value to return if the value is null or cannot be converted. /// The value at the given index as a long. - public static long ConvertValueToInt64(this System.Data.IDataReader reader, int index, long defaultvalue = -1) + public static long ConvertValueToInt64(this IDataReader reader, int index, long defaultvalue = -1) { try { @@ -440,12 +429,27 @@ public static class ExtensionMethods /// /// The connection to create the command on. /// The transaction to use for the command. + /// The command string to create the command with. /// A new command with the given transaction. - public static System.Data.IDbCommand CreateCommand(this System.Data.IDbConnection self, System.Data.IDbTransaction transaction) + public static IDbCommand CreateCommand(this IDbConnection self, IDbTransaction? transaction, string? cmdtext = null) { - var con = self.CreateCommand(); - con.Transaction = transaction; - return con; + var cmd = self.CreateCommand(); + cmd.Transaction = transaction; + if (!string.IsNullOrEmpty(cmdtext)) + { + cmd.CommandText = cmdtext; + cmd.AddParameters(cmdtext.Count(c => c == '?')); + } + return cmd; } + + /// + /// Creates a command with the given command string, and adds parameters to fit the input + /// + /// The connection to create the command on + /// The command string to create the command with + /// The command with the parameters added + public static IDbCommand CreateCommand(this IDbConnection self, string cmdtext) + => CreateCommand(self, null, cmdtext); } diff --git a/Duplicati/Library/Main/Database/LocalBackupDatabase.cs b/Duplicati/Library/Main/Database/LocalBackupDatabase.cs index 1579cce32..6d121a8ff 100644 --- a/Duplicati/Library/Main/Database/LocalBackupDatabase.cs +++ b/Duplicati/Library/Main/Database/LocalBackupDatabase.cs @@ -22,6 +22,7 @@ using System; using System.Collections.Generic; using System.Data; using System.Linq; +using Duplicati.Library.Common.IO; namespace Duplicati.Library.Main.Database { @@ -184,14 +185,10 @@ namespace Duplicati.Library.Main.Database @" WHERE ""A"".""ID"" = ""B"".""FileID"" " + @" AND ""B"".""FilesetID"" = ? "; - m_findfileCommand.CommandText = string.Format( - @"SELECT ""C"".""ID"" AS ""FileID"", ""C"".""LastModified"", ""D"".""Length"", ""E"".""FullHash"" as ""Metahash"", ""E"".""Length"" AS ""Metasize"" " + - @" FROM " + - @" ({0}) AS ""C"", ""Blockset"" AS ""D"", ""Blockset"" AS ""E"", ""Metadataset"" ""F"" " + - @" WHERE ""C"".""BlocksetID"" == ""D"".""ID"" AND ""C"".""MetadataID"" == ""F"".""ID"" AND ""F"".""BlocksetID"" = ""E"".""ID"" " + - @" LIMIT 1", - getLastFileEntryForPath - ); + m_findfileCommand.CommandText = FormatInvariant($@"SELECT ""C"".""ID"" AS ""FileID"", ""C"".""LastModified"", ""D"".""Length"", ""E"".""FullHash"" as ""Metahash"", ""E"".""Length"" AS ""Metasize"" +FROM ({getLastFileEntryForPath}) AS ""C"", ""Blockset"" AS ""D"", ""Blockset"" AS ""E"", ""Metadataset"" ""F"" +WHERE ""C"".""BlocksetID"" == ""D"".""ID"" AND ""C"".""MetadataID"" == ""F"".""ID"" AND ""F"".""BlocksetID"" = ""E"".""ID"" +LIMIT 1"); break; // Potentially faster query: https://forum.duplicati.com/t/release-2-0-3-10-canary-2018-08-30/4497/25 @@ -342,12 +339,12 @@ SELECT ""BlocklistHash"".""BlocksetID"" FROM ""BlocklistHash"" WHERE ""Blocklist { var bid = cmd.ExecuteScalarInt64(@"SELECT ""ID"" FROM ""Block"" WHERE ""Hash"" = ?", -1, h); if (bid == -1) - throw new Exception(string.Format("Could not find any blocks with the given hash: {0}", h)); + throw new Exception($"Could not find any blocks with the given hash: {h}"); foreach (var rd in cmd.ExecuteReaderEnumerable(@"SELECT ""Size"" FROM ""Block"" WHERE ""Hash"" = ?", h)) Logging.Log.WriteErrorMessage(LOGTAG, "FoundIssue1400Error", null, "Found block with ID {0} and hash {1} and size {2}", bid, h, rd.ConvertValueToInt64(0, -1)); } - throw new Exception(string.Format("Unexpected result count: {0}, expected {1}, check log for more messages", c, 1)); + throw new Exception($"Unexpected result count: {c}, expected {1}, check log for more messages"); } ix++; @@ -615,22 +612,22 @@ SELECT ""BlocklistHash"".""BlocksetID"" FROM ""BlocklistHash"" WHERE ""Blocklist { var subqueryFiles = @"SELECT ""File"".""Path"" AS ""Path"", ""A"".""Fullhash"" AS ""Filehash"", ""B"".""Fullhash"" AS ""Metahash"" FROM ""File"", ""FilesetEntry"", ""Blockset"" A, ""Blockset"" B, ""Metadataset"" WHERE ""File"".""ID"" = ""FilesetEntry"".""FileID"" AND ""A"".""ID"" = ""File"".""BlocksetID"" AND ""FilesetEntry"".""FilesetID"" = ? AND ""File"".""MetadataID"" = ""Metadataset"".""ID"" AND ""Metadataset"".""BlocksetID"" = ""B"".""ID"" "; - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" AS " + subqueryFiles, tmpName1), lastFilesetId); - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" AS " + subqueryFiles, tmpName2), m_filesetId); - cmd.ExecuteNonQuery(string.Format(@"CREATE INDEX ""nn_tmpName1"" ON ""{0}"" (""Path"")", tmpName1)); - cmd.ExecuteNonQuery(string.Format(@"CREATE INDEX ""nn_tmpName2"" ON ""{0}"" (""Path"")", tmpName2)); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{tmpName1}"" AS {subqueryFiles}"), lastFilesetId); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{tmpName2}"" AS {subqueryFiles}"), m_filesetId); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE INDEX ""nn_tmpName1"" ON ""{tmpName1}"" (""Path"")")); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE INDEX ""nn_tmpName2"" ON ""{tmpName2}"" (""Path"")")); - results.AddedFiles = cmd.ExecuteScalarInt64(string.Format(@"SELECT COUNT(*) FROM ""File"" INNER JOIN ""FilesetEntry"" ON ""File"".""ID"" = ""FilesetEntry"".""FileID"" WHERE ""FilesetEntry"".""FilesetID"" = ? AND ""File"".""BlocksetID"" != ? AND ""File"".""BlocksetID"" != ? AND NOT ""File"".""Path"" IN (SELECT ""Path"" FROM ""{0}"")", tmpName1), 0, m_filesetId, FOLDER_BLOCKSET_ID, SYMLINK_BLOCKSET_ID); - results.DeletedFiles = cmd.ExecuteScalarInt64(string.Format(@"SELECT COUNT(*) FROM ""{0}"" WHERE ""{0}"".""Path"" NOT IN (SELECT ""Path"" FROM ""File"" INNER JOIN ""FilesetEntry"" ON ""File"".""ID"" = ""FilesetEntry"".""FileID"" WHERE ""FilesetEntry"".""FilesetID"" = ?)", tmpName1), 0, m_filesetId); - results.ModifiedFiles = cmd.ExecuteScalarInt64(string.Format(@"SELECT COUNT(*) FROM ""{0}"" A, ""{1}"" B WHERE ""A"".""Path"" = ""B"".""Path"" AND (""A"".""Filehash"" != ""B"".""Filehash"" OR ""A"".""Metahash"" != ""B"".""Metahash"")", tmpName1, tmpName2), 0); + results.AddedFiles = cmd.ExecuteScalarInt64(FormatInvariant($@"SELECT COUNT(*) FROM ""File"" INNER JOIN ""FilesetEntry"" ON ""File"".""ID"" = ""FilesetEntry"".""FileID"" WHERE ""FilesetEntry"".""FilesetID"" = ? AND ""File"".""BlocksetID"" != ? AND ""File"".""BlocksetID"" != ? AND NOT ""File"".""Path"" IN (SELECT ""Path"" FROM ""{tmpName1}"")"), 0, m_filesetId, FOLDER_BLOCKSET_ID, SYMLINK_BLOCKSET_ID); + results.DeletedFiles = cmd.ExecuteScalarInt64(FormatInvariant($@"SELECT COUNT(*) FROM ""{tmpName1}"" WHERE ""{tmpName1}"".""Path"" NOT IN (SELECT ""Path"" FROM ""File"" INNER JOIN ""FilesetEntry"" ON ""File"".""ID"" = ""FilesetEntry"".""FileID"" WHERE ""FilesetEntry"".""FilesetID"" = ?)"), 0, m_filesetId); + results.ModifiedFiles = cmd.ExecuteScalarInt64(FormatInvariant($@"SELECT COUNT(*) FROM ""{tmpName1}"" A, ""{tmpName2}"" B WHERE ""A"".""Path"" = ""B"".""Path"" AND (""A"".""Filehash"" != ""B"".""Filehash"" OR ""A"".""Metahash"" != ""B"".""Metahash"")"), 0); } finally { - try { cmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"";", tmpName1)); } + try { cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{tmpName1}"";")); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "DisposeError", ex, "Dispose temp table error"); } - try { cmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"";", tmpName2)); } + try { cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{tmpName2}"";")); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "DisposeError", ex, "Dispose temp table error"); } } } @@ -725,21 +722,21 @@ SELECT ""BlocklistHash"".""BlocksetID"" FROM ""BlocklistHash"" WHERE ""Blocklist // copy entries from previous file set into a temporary table, except those file IDs already added by the current backup var tempFileSetTable = "FilesetEntry-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); cmd.Transaction = tr.Parent; - cmd.ExecuteNonQuery($@"CREATE TEMPORARY TABLE ""{tempFileSetTable}"" AS SELECT ""FileID"", ""Lastmodified"" FROM (SELECT DISTINCT ""FilesetID"", ""FileID"", ""Lastmodified"" FROM ""FilesetEntry"" WHERE ""FilesetID"" = ? AND ""FileID"" NOT IN (SELECT ""FileID"" FROM ""FilesetEntry"" WHERE ""FilesetID"" = ?))", lastFilesetId, fileSetId); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{tempFileSetTable}"" AS SELECT ""FileID"", ""Lastmodified"" FROM (SELECT DISTINCT ""FilesetID"", ""FileID"", ""Lastmodified"" FROM ""FilesetEntry"" WHERE ""FilesetID"" = ? AND ""FileID"" NOT IN (SELECT ""FileID"" FROM ""FilesetEntry"" WHERE ""FilesetID"" = ?))"), lastFilesetId, fileSetId); // now we need to remove, from the above, any entries that were enumerated by the // UNC-driven backup cmdDelete.Transaction = tr.Parent; - cmdDelete.CommandText = $@"DELETE FROM ""{tempFileSetTable}"" WHERE ""FileID"" = ?"; + cmdDelete.CommandText = FormatInvariant($@"DELETE FROM ""{tempFileSetTable}"" WHERE ""FileID"" = ?"); cmdDelete.AddParameters(1); // enumerate files from new temporary file set, and remove any entries handled by UNC cmd.Transaction = tr.Parent; - foreach (var row in cmd.ExecuteReaderEnumerable( + foreach (var row in cmd.ExecuteReaderEnumerable(FormatInvariant( $@"SELECT f.""Path"", fs.""FileID"", fs.""Lastmodified"", COALESCE(bs.""Length"", -1) FROM (SELECT DISTINCT ""FileID"", ""Lastmodified"" FROM ""{tempFileSetTable}"") AS fs LEFT JOIN ""File"" AS f ON fs.""FileID"" = f.""ID"" - LEFT JOIN ""Blockset"" AS bs ON f.""BlocksetID"" = bs.""ID"";")) + LEFT JOIN ""Blockset"" AS bs ON f.""BlocksetID"" = bs.""ID"";"))) { var path = row.GetString(0); var size = row.GetInt64(3); @@ -752,8 +749,8 @@ SELECT ""BlocklistHash"".""BlocksetID"" FROM ""BlocklistHash"" WHERE ""Blocklist } // now copy the temporary table into the FileSetEntry table - cmd.ExecuteNonQuery($@"INSERT INTO ""FilesetEntry"" (""FilesetID"", ""FileID"", ""Lastmodified"") - SELECT ?, ""FileID"", ""Lastmodified"" FROM ""{tempFileSetTable}""", fileSetId); + cmd.ExecuteNonQuery(FormatInvariant($@"INSERT INTO ""FilesetEntry"" (""FilesetID"", ""FileID"", ""Lastmodified"") + SELECT ?, ""FileID"", ""Lastmodified"" FROM ""{tempFileSetTable}"""), fileSetId); tr.Commit(); } @@ -770,40 +767,6 @@ SELECT ""BlocklistHash"".""BlocksetID"" FROM ""BlocklistHash"" WHERE ""Blocklist return m_filesetId = base.CreateFileset(volumeid, timestamp, transaction); } - public IEnumerable> GetIncompleteFilesets(System.Data.IDbTransaction transaction) - { - using (var cmd = m_connection.CreateCommand(transaction)) - { - using (var rd = cmd.ExecuteReader(@$"SELECT DISTINCT ""Fileset"".""ID"", ""Fileset"".""Timestamp"" FROM ""Fileset"", ""RemoteVolume"" WHERE ""RemoteVolume"".""ID"" = ""Fileset"".""VolumeID"" AND ""Fileset"".""ID"" IN (SELECT ""FilesetID"" FROM ""FilesetEntry"") AND (""RemoteVolume"".""State"" = '{RemoteVolumeState.Uploading}' OR ""RemoteVolume"".""State"" = '{RemoteVolumeState.Temporary}')")) - while (rd.Read()) - { - yield return new KeyValuePair( - rd.GetInt64(0), - ParseFromEpochSeconds(rd.GetInt64(1)).ToLocalTime() - ); - } - } - } - - public RemoteVolumeEntry GetRemoteVolumeFromFilesetID(long filesetID, IDbTransaction transaction = null) - { - using (var cmd = m_connection.CreateCommand(transaction)) - using (var rd = cmd.ExecuteReader(@"SELECT ""RemoteVolume"".""ID"", ""Name"", ""Type"", ""Size"", ""Hash"", ""State"", ""DeleteGraceTime"", ""ArchiveTime"" FROM ""RemoteVolume"", ""Fileset"" WHERE ""Fileset"".""VolumeID"" = ""RemoteVolume"".""ID"" AND ""Fileset"".""ID"" = ?", filesetID)) - if (rd.Read()) - return new RemoteVolumeEntry( - rd.ConvertValueToInt64(0, -1), - rd.GetValue(1).ToString(), - (rd.GetValue(4) == null || rd.GetValue(4) == DBNull.Value) ? null : rd.GetValue(4).ToString(), - rd.ConvertValueToInt64(3, -1), - (RemoteVolumeType)Enum.Parse(typeof(RemoteVolumeType), rd.GetValue(2).ToString()), - (RemoteVolumeState)Enum.Parse(typeof(RemoteVolumeState), rd.GetValue(5).ToString()), - new DateTime(rd.ConvertValueToInt64(6, 0), DateTimeKind.Utc), - new DateTime(rd.ConvertValueToInt64(7, 0), DateTimeKind.Utc) - ); - else - return default(RemoteVolumeEntry); - } - public IEnumerable GetTemporaryFilelistVolumeNames(bool latestOnly, IDbTransaction transaction = null) { var incompleteFilesetIDs = GetIncompleteFilesets(transaction).OrderBy(x => x.Value).Select(x => x.Key).ToArray(); @@ -829,17 +792,6 @@ SELECT ""BlocklistHash"".""BlocksetID"" FROM ""BlocklistHash"" WHERE ""Blocklist yield return rd.GetValue(0).ToString(); } - public void LinkFilesetToVolume(long filesetid, long volumeid, System.Data.IDbTransaction transaction) - { - using (var cmd = m_connection.CreateCommand()) - { - cmd.Transaction = transaction; - var c = cmd.ExecuteNonQuery(@"UPDATE ""Fileset"" SET ""VolumeID"" = ? WHERE ""ID"" = ?", volumeid, filesetid); - if (c != 1) - throw new Exception(string.Format("Failed to link filesetid {0} to volumeid {1}", filesetid, volumeid)); - } - } - public void MoveBlockToVolume(string blockkey, long size, long sourcevolumeid, long targetvolumeid, System.Data.IDbTransaction transaction) { using (var cmd = m_connection.CreateCommand()) @@ -847,7 +799,7 @@ SELECT ""BlocklistHash"".""BlocksetID"" FROM ""BlocklistHash"" WHERE ""Blocklist cmd.Transaction = transaction; var c = cmd.ExecuteNonQuery(@"UPDATE ""Block"" SET ""VolumeID"" = ? WHERE ""Hash"" = ? AND ""Size"" = ? AND ""VolumeID"" = ? ", targetvolumeid, blockkey, size, sourcevolumeid); if (c != 1) - throw new Exception(string.Format("Failed to move block {0}:{1} from volume {2}, count: {3}", blockkey, size, sourcevolumeid, c)); + throw new Exception($"Failed to move block {blockkey}:{size} from volume {sourcevolumeid}, count: {c}"); } } @@ -859,7 +811,7 @@ SELECT ""BlocklistHash"".""BlocksetID"" FROM ""BlocklistHash"" WHERE ""Blocklist { var c = cmd.ExecuteScalarInt64(@"SELECT COUNT(*) FROM ""Block"" WHERE ""VolumeID"" = ? ", -1, volumeid); if (c != 0) - throw new Exception(string.Format("Failed to safe-delete volume {0}, blocks: {1}", name, c)); + throw new Exception($"Failed to safe-delete volume {name}, blocks: {c}"); RemoveRemoteVolume(name, transaction); } diff --git a/Duplicati/Library/Main/Database/LocalBugReportDatabase.cs b/Duplicati/Library/Main/Database/LocalBugReportDatabase.cs index 604479906..bcf56106b 100644 --- a/Duplicati/Library/Main/Database/LocalBugReportDatabase.cs +++ b/Duplicati/Library/Main/Database/LocalBugReportDatabase.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -40,22 +40,22 @@ namespace Duplicati.Library.Main.Database public void Fix() { - using(var tr = m_connection.BeginTransaction()) - using(var cmd = m_connection.CreateCommand()) + using (var tr = m_connection.BeginTransaction()) + using (var cmd = m_connection.CreateCommand()) { cmd.Transaction = tr; var tablename = "PathMap-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); // TODO: Rewrite this to use PathPrefix // TODO: Needs to be much faster - using(var upcmd = m_connection.CreateCommand()) + using (var upcmd = m_connection.CreateCommand()) { - + upcmd.Transaction = tr; - upcmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" (""ID"" INTEGER PRIMARY KEY, ""RealPath"" TEXT NOT NULL, ""Obfuscated"" TEXT NULL)", tablename)); - upcmd.ExecuteNonQuery(string.Format(@"INSERT INTO ""{0}"" (""RealPath"") SELECT DISTINCT ""Path"" FROM ""File"" ORDER BY ""Path"" ", tablename)); - upcmd.ExecuteNonQuery(string.Format(@"UPDATE ""{0}"" SET ""Obfuscated"" = ? || length(""RealPath"") || ? || ""ID"" || (CASE WHEN substr(""RealPath"", length(""RealPath"")) = ? THEN ? ELSE ? END) ", tablename), !OperatingSystem.IsWindows() ? "/" : "X:\\", Util.DirectorySeparatorString, Util.DirectorySeparatorString, Util.DirectorySeparatorString, ".bin"); - + upcmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{tablename}"" (""ID"" INTEGER PRIMARY KEY, ""RealPath"" TEXT NOT NULL, ""Obfuscated"" TEXT NULL)")); + upcmd.ExecuteNonQuery(FormatInvariant($@"INSERT INTO ""{tablename}"" (""RealPath"") SELECT DISTINCT ""Path"" FROM ""File"" ORDER BY ""Path"" ")); + upcmd.ExecuteNonQuery(FormatInvariant($@"UPDATE ""{tablename}"" SET ""Obfuscated"" = ? || length(""RealPath"") || ? || ""ID"" || (CASE WHEN substr(""RealPath"", length(""RealPath"")) = ? THEN ? ELSE ? END) "), !OperatingSystem.IsWindows() ? "/" : "X:\\", Util.DirectorySeparatorString, Util.DirectorySeparatorString, Util.DirectorySeparatorString, ".bin"); + /*long id = 1; using(var rd = cmd.ExecuteReader(string.Format(@"SELECT ""RealPath"", ""Obfuscated"" FROM ""{0}"" ", tablename))) while(rd.Read()) @@ -66,21 +66,21 @@ namespace Duplicati.Library.Main.Database */ } - cmd.ExecuteNonQuery(@"UPDATE ""LogData"" SET ""Message"" = 'ERASED!' WHERE ""Message"" LIKE '%/%' OR ""Message"" LIKE '%:\%' "); - cmd.ExecuteNonQuery(@"UPDATE ""LogData"" SET ""Exception"" = 'ERASED!' WHERE ""Exception"" LIKE '%/%' OR ""Exception"" LIKE '%:\%' "); + cmd.ExecuteNonQuery(@"UPDATE ""LogData"" SET ""Message"" = 'ERASED!' WHERE ""Message"" LIKE '%/%' OR ""Message"" LIKE '%:\%' "); + cmd.ExecuteNonQuery(@"UPDATE ""LogData"" SET ""Exception"" = 'ERASED!' WHERE ""Exception"" LIKE '%/%' OR ""Exception"" LIKE '%:\%' "); cmd.ExecuteNonQuery(@"UPDATE ""Configuration"" SET ""Value"" = 'ERASED!' WHERE ""Key"" = 'passphrase' "); - cmd.ExecuteNonQuery(string.Format(@"CREATE TABLE ""FixedFile"" AS SELECT ""B"".""ID"" AS ""ID"", ""A"".""Obfuscated"" AS ""Path"", ""B"".""BlocksetID"" AS ""BlocksetID"", ""B"".""MetadataID"" AS ""MetadataID"" FROM ""{0}"" ""A"", ""File"" ""B"" WHERE ""A"".""RealPath"" = ""B"".""Path"" ", tablename)); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TABLE ""FixedFile"" AS SELECT ""B"".""ID"" AS ""ID"", ""A"".""Obfuscated"" AS ""Path"", ""B"".""BlocksetID"" AS ""BlocksetID"", ""B"".""MetadataID"" AS ""MetadataID"" FROM ""{tablename}"" ""A"", ""File"" ""B"" WHERE ""A"".""RealPath"" = ""B"".""Path"" ")); cmd.ExecuteNonQuery(@"DROP VIEW ""File"" "); cmd.ExecuteNonQuery(@"DROP TABLE ""FileLookup"" "); cmd.ExecuteNonQuery(@"DROP TABLE ""PathPrefix"" "); - cmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"" ", tablename)); - - using(new Logging.Timer(LOGTAG, "CommitUpdateBugReport", "CommitUpdateBugReport")) + cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{tablename}"" ")); + + using (new Logging.Timer(LOGTAG, "CommitUpdateBugReport", "CommitUpdateBugReport")) tr.Commit(); - + cmd.Transaction = null; cmd.ExecuteNonQuery("VACUUM"); diff --git a/Duplicati/Library/Main/Database/LocalDatabase.cs b/Duplicati/Library/Main/Database/LocalDatabase.cs index c770c0705..1a23a71d9 100644 --- a/Duplicati/Library/Main/Database/LocalDatabase.cs +++ b/Duplicati/Library/Main/Database/LocalDatabase.cs @@ -27,6 +27,7 @@ using System.IO; using Duplicati.Library.Modules.Builtin.ResultSerialization; using Duplicati.Library.Utility; using System.Runtime.CompilerServices; +using Duplicati.Library.Interface; // Expose internal classes to UnitTests, so that Database classes can be tested @@ -41,25 +42,25 @@ namespace Duplicati.Library.Main.Database /// private static readonly string LOGTAG = Logging.Log.LogTagFromType(typeof(LocalDatabase)); - protected readonly System.Data.IDbConnection m_connection; + protected readonly IDbConnection m_connection; protected readonly long m_operationid = -1; private bool m_hasExecutedVacuum; - private readonly System.Data.IDbCommand m_updateremotevolumeCommand; - private readonly System.Data.IDbCommand m_selectremotevolumesCommand; - private readonly System.Data.IDbCommand m_selectremotevolumeCommand; - private readonly System.Data.IDbCommand m_removeremotevolumeCommand; - private readonly System.Data.IDbCommand m_removedeletedremotevolumeCommand; - private readonly System.Data.IDbCommand m_selectremotevolumeIdCommand; - private readonly System.Data.IDbCommand m_createremotevolumeCommand; - private readonly System.Data.IDbCommand m_selectduplicateRemoteVolumesCommand; + private readonly IDbCommand m_updateremotevolumeCommand; + private readonly IDbCommand m_selectremotevolumesCommand; + private readonly IDbCommand m_selectremotevolumeCommand; + private readonly IDbCommand m_removeremotevolumeCommand; + private readonly IDbCommand m_removedeletedremotevolumeCommand; + private readonly IDbCommand m_selectremotevolumeIdCommand; + private readonly IDbCommand m_createremotevolumeCommand; + private readonly IDbCommand m_selectduplicateRemoteVolumesCommand; - private readonly System.Data.IDbCommand m_insertlogCommand; - private readonly System.Data.IDbCommand m_insertremotelogCommand; - private readonly System.Data.IDbCommand m_insertIndexBlockLink; + private readonly IDbCommand m_insertlogCommand; + private readonly IDbCommand m_insertremotelogCommand; + private readonly IDbCommand m_insertIndexBlockLink; - private readonly System.Data.IDbCommand m_findpathprefixCommand; - private readonly System.Data.IDbCommand m_insertpathprefixCommand; + private readonly IDbCommand m_findpathprefixCommand; + private readonly IDbCommand m_insertpathprefixCommand; protected BasicResults m_result; @@ -68,23 +69,23 @@ namespace Duplicati.Library.Main.Database public DateTime OperationTimestamp { get; private set; } - internal System.Data.IDbConnection Connection { get { return m_connection; } } + internal IDbConnection Connection { get { return m_connection; } } public bool IsDisposed { get; private set; } public bool ShouldCloseConnection { get; set; } - protected static System.Data.IDbConnection CreateConnection(string path) + protected static IDbConnection CreateConnection(string path) { - path = System.IO.Path.GetFullPath(path); - if (!System.IO.Directory.Exists(System.IO.Path.GetDirectoryName(path))) - System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)); + path = Path.GetFullPath(path); + if (!Directory.Exists(Path.GetDirectoryName(path))) + Directory.CreateDirectory(Path.GetDirectoryName(path)); - var c = Duplicati.Library.SQLiteHelper.SQLiteLoader.LoadConnection(path); + var c = SQLiteHelper.SQLiteLoader.LoadConnection(path); try { - Library.SQLiteHelper.DatabaseUpgrader.UpgradeDatabase(c, path, typeof(LocalDatabase)); + SQLiteHelper.DatabaseUpgrader.UpgradeDatabase(c, path, typeof(LocalDatabase)); } catch { @@ -96,6 +97,14 @@ namespace Duplicati.Library.Main.Database return c; } + /// + /// Formats the string using the invariant culture + /// + /// The formattable string + /// The formatted string + public static string FormatInvariant(FormattableString formattable) + => Library.Utility.Utility.FormatInvariant(formattable); + public static bool Exists(string path) { return File.Exists(path); @@ -118,23 +127,23 @@ namespace Duplicati.Library.Main.Database public LocalDatabase(LocalDatabase db) : this(db.m_connection) { - this.OperationTimestamp = db.OperationTimestamp; - this.m_connection = db.m_connection; - this.m_operationid = db.m_operationid; - this.m_result = db.m_result; + OperationTimestamp = db.OperationTimestamp; + m_connection = db.m_connection; + m_operationid = db.m_operationid; + m_result = db.m_result; } /// /// Creates a new database instance and starts a new operation /// /// The name of the operation. If null, continues last operation - public LocalDatabase(System.Data.IDbConnection connection, string operation) + public LocalDatabase(IDbConnection connection, string operation) : this(connection) { - this.OperationTimestamp = DateTime.UtcNow; + OperationTimestamp = DateTime.UtcNow; m_connection = connection; - if (m_connection.State != System.Data.ConnectionState.Open) + if (m_connection.State != ConnectionState.Open) m_connection.Open(); if (operation != null) @@ -158,57 +167,21 @@ namespace Duplicati.Library.Main.Database } } - private LocalDatabase(System.Data.IDbConnection connection) + private LocalDatabase(IDbConnection connection) { - m_updateremotevolumeCommand = connection.CreateCommand(); - m_selectremotevolumesCommand = connection.CreateCommand(); - m_selectduplicateRemoteVolumesCommand = connection.CreateCommand(); - m_selectremotevolumeCommand = connection.CreateCommand(); - m_insertlogCommand = connection.CreateCommand(); - m_insertremotelogCommand = connection.CreateCommand(); - m_removeremotevolumeCommand = connection.CreateCommand(); - m_removedeletedremotevolumeCommand = connection.CreateCommand(); - m_selectremotevolumeIdCommand = connection.CreateCommand(); - m_createremotevolumeCommand = connection.CreateCommand(); - m_insertIndexBlockLink = connection.CreateCommand(); - m_findpathprefixCommand = connection.CreateCommand(); - m_insertpathprefixCommand = connection.CreateCommand(); - - m_insertlogCommand.CommandText = @"INSERT INTO ""LogData"" (""OperationID"", ""Timestamp"", ""Type"", ""Message"", ""Exception"") VALUES (?, ?, ?, ?, ?)"; - m_insertlogCommand.AddParameters(5); - - m_insertremotelogCommand.CommandText = @"INSERT INTO ""RemoteOperation"" (""OperationID"", ""Timestamp"", ""Operation"", ""Path"", ""Data"") VALUES (?, ?, ?, ?, ?)"; - m_insertremotelogCommand.AddParameters(5); - - m_updateremotevolumeCommand.CommandText = @"UPDATE ""Remotevolume"" SET ""OperationID"" = ?, ""State"" = ?, ""Hash"" = ?, ""Size"" = ? WHERE ""Name"" = ?"; - m_updateremotevolumeCommand.AddParameters(5); - - m_selectremotevolumesCommand.CommandText = @"SELECT ""ID"", ""Name"", ""Type"", ""Size"", ""Hash"", ""State"", ""DeleteGraceTime"", ""ArchiveTime"" FROM ""Remotevolume"""; - - m_selectremotevolumeCommand.CommandText = m_selectremotevolumesCommand.CommandText + @" WHERE ""Name"" = ?"; - m_selectduplicateRemoteVolumesCommand.CommandText = string.Format(@"SELECT DISTINCT ""Name"", ""State"" FROM ""Remotevolume"" WHERE ""Name"" IN (SELECT ""Name"" FROM ""Remotevolume"" WHERE ""State"" IN ('{0}', '{1}')) AND NOT ""State"" IN ('{0}', '{1}')", RemoteVolumeState.Deleted.ToString(), RemoteVolumeState.Deleting.ToString()); - - m_selectremotevolumeCommand.AddParameter(); - - m_removeremotevolumeCommand.CommandText = @"DELETE FROM ""Remotevolume"" WHERE ""Name"" = ? AND (""DeleteGraceTime"" < ? OR ""State"" != ?)"; - m_removeremotevolumeCommand.AddParameters(3); - - m_removedeletedremotevolumeCommand.CommandText = $@"DELETE FROM ""Remotevolume"" WHERE ""State"" == '{RemoteVolumeState.Deleted.ToString()}' AND (""DeleteGraceTime"" < ? OR LENGTH(""DeleteGraceTime"") > 12) "; // >12 is to handle removal of old records that were in ticks - m_removedeletedremotevolumeCommand.AddParameter(); - - m_selectremotevolumeIdCommand.CommandText = @"SELECT ""ID"" FROM ""Remotevolume"" WHERE ""Name"" = ?"; - - m_createremotevolumeCommand.CommandText = @"INSERT INTO ""Remotevolume"" (""OperationID"", ""Name"", ""Type"", ""State"", ""Size"", ""VerificationCount"", ""DeleteGraceTime"") VALUES (?, ?, ?, ?, ?, ?, ?); SELECT last_insert_rowid();"; - m_createremotevolumeCommand.AddParameters(7); - - m_insertIndexBlockLink.CommandText = @"INSERT INTO ""IndexBlockLink"" (""IndexVolumeID"", ""BlockVolumeID"") VALUES (?, ?)"; - m_insertIndexBlockLink.AddParameters(2); - - m_findpathprefixCommand.CommandText = @"SELECT ""ID"" FROM ""PathPrefix"" WHERE ""Prefix"" = ?"; - m_findpathprefixCommand.AddParameter(); - - m_insertpathprefixCommand.CommandText = @"INSERT INTO ""PathPrefix"" (""Prefix"") VALUES (?); SELECT last_insert_rowid(); "; - m_insertpathprefixCommand.AddParameter(); + m_insertlogCommand = connection.CreateCommand(@"INSERT INTO ""LogData"" (""OperationID"", ""Timestamp"", ""Type"", ""Message"", ""Exception"") VALUES (?, ?, ?, ?, ?)"); + m_insertremotelogCommand = connection.CreateCommand(@"INSERT INTO ""RemoteOperation"" (""OperationID"", ""Timestamp"", ""Operation"", ""Path"", ""Data"") VALUES (?, ?, ?, ?, ?)"); + m_updateremotevolumeCommand = connection.CreateCommand(@"UPDATE ""Remotevolume"" SET ""OperationID"" = ?, ""State"" = ?, ""Hash"" = ?, ""Size"" = ? WHERE ""Name"" = ?"); + m_selectremotevolumesCommand = connection.CreateCommand(@"SELECT ""ID"", ""Name"", ""Type"", ""Size"", ""Hash"", ""State"", ""DeleteGraceTime"", ""ArchiveTime"" FROM ""Remotevolume"""); + m_selectremotevolumeCommand = connection.CreateCommand(m_selectremotevolumesCommand.CommandText + @" WHERE ""Name"" = ?"); + m_selectduplicateRemoteVolumesCommand = connection.CreateCommand(FormatInvariant($@"SELECT DISTINCT ""Name"", ""State"" FROM ""Remotevolume"" WHERE ""Name"" IN (SELECT ""Name"" FROM ""Remotevolume"" WHERE ""State"" IN ('{RemoteVolumeState.Deleted.ToString()}', '{RemoteVolumeState.Deleting.ToString()}')) AND NOT ""State"" IN ('{RemoteVolumeState.Deleted.ToString()}', '{RemoteVolumeState.Deleting.ToString()}')")); + m_removeremotevolumeCommand = connection.CreateCommand(@"DELETE FROM ""Remotevolume"" WHERE ""Name"" = ? AND (""DeleteGraceTime"" < ? OR ""State"" != ?)"); + m_removedeletedremotevolumeCommand = connection.CreateCommand(FormatInvariant($@"DELETE FROM ""Remotevolume"" WHERE ""State"" == '{RemoteVolumeState.Deleted.ToString()}' AND (""DeleteGraceTime"" < ? OR LENGTH(""DeleteGraceTime"") > 12) ")); // >12 is to handle removal of old records that were in ticks + m_selectremotevolumeIdCommand = connection.CreateCommand(@"SELECT ""ID"" FROM ""Remotevolume"" WHERE ""Name"" = ?"); + m_createremotevolumeCommand = connection.CreateCommand(@"INSERT INTO ""Remotevolume"" (""OperationID"", ""Name"", ""Type"", ""State"", ""Size"", ""VerificationCount"", ""DeleteGraceTime"") VALUES (?, ?, ?, ?, ?, ?, ?); SELECT last_insert_rowid();"); + m_insertIndexBlockLink = connection.CreateCommand(@"INSERT INTO ""IndexBlockLink"" (""IndexVolumeID"", ""BlockVolumeID"") VALUES (?, ?)"); + m_findpathprefixCommand = connection.CreateCommand(@"SELECT ""ID"" FROM ""PathPrefix"" WHERE ""Prefix"" = ?"); + m_insertpathprefixCommand = connection.CreateCommand(@"INSERT INTO ""PathPrefix"" (""Prefix"") VALUES (?); SELECT last_insert_rowid(); "); } internal void SetResult(BasicResults result) @@ -224,17 +197,17 @@ namespace Duplicati.Library.Main.Database return Library.Utility.Utility.EPOCH.AddSeconds(seconds); } - public void UpdateRemoteVolume(string name, RemoteVolumeState state, long size, string hash, System.Data.IDbTransaction transaction = null) + public void UpdateRemoteVolume(string name, RemoteVolumeState state, long size, string hash, IDbTransaction transaction = null) { UpdateRemoteVolume(name, state, size, hash, false, transaction); } - public void UpdateRemoteVolume(string name, RemoteVolumeState state, long size, string hash, bool suppressCleanup, System.Data.IDbTransaction transaction = null) + public void UpdateRemoteVolume(string name, RemoteVolumeState state, long size, string hash, bool suppressCleanup, IDbTransaction transaction = null) { UpdateRemoteVolume(name, state, size, hash, suppressCleanup, new TimeSpan(0), null, transaction); } - public void UpdateRemoteVolume(string name, RemoteVolumeState state, long size, string hash, bool suppressCleanup, TimeSpan deleteGraceTime, bool? setArchived, System.Data.IDbTransaction transaction = null) + public void UpdateRemoteVolume(string name, RemoteVolumeState state, long size, string hash, bool suppressCleanup, TimeSpan deleteGraceTime, bool? setArchived, IDbTransaction transaction = null) { m_updateremotevolumeCommand.Transaction = transaction; m_updateremotevolumeCommand.SetParameterValue(0, m_operationid); @@ -297,8 +270,8 @@ namespace Duplicati.Library.Main.Database public Tuple GetFilelistWhereClause(DateTime time, long[] versions, IEnumerable> filesetslist = null, bool singleTimeMatch = false) { - var filesets = (filesetslist ?? this.FilesetTimes).ToArray(); - StringBuilder query = new StringBuilder(); + var filesets = (filesetslist ?? FilesetTimes).ToArray(); + var query = new StringBuilder(); var args = new List(); if (time.Ticks > 0 || (versions != null && versions.Length > 0)) { @@ -316,8 +289,7 @@ namespace Duplicati.Library.Main.Database if (versions != null && versions.Length > 0) { - StringBuilder qs = new StringBuilder(); - + var qs = new StringBuilder(); foreach (var v in versions) { if (v >= 0 && v < filesets.Length) @@ -347,13 +319,13 @@ namespace Duplicati.Library.Main.Database return new Tuple(query.ToString(), args.ToArray()); } - public long GetRemoteVolumeID(string file, System.Data.IDbTransaction transaction = null) + public long GetRemoteVolumeID(string file, IDbTransaction transaction = null) { m_selectremotevolumeIdCommand.Transaction = transaction; return m_selectremotevolumeIdCommand.ExecuteScalarInt64(null, -1, file); } - public IEnumerable> GetRemoteVolumeIDs(IEnumerable files, System.Data.IDbTransaction transaction = null) + public IEnumerable> GetRemoteVolumeIDs(IEnumerable files, IDbTransaction transaction = null) { using (var cmd = m_connection.CreateCommand(transaction)) { @@ -367,7 +339,7 @@ namespace Duplicati.Library.Main.Database } } - public RemoteVolumeEntry GetRemoteVolume(string file, System.Data.IDbTransaction transaction = null) + public RemoteVolumeEntry GetRemoteVolume(string file, IDbTransaction transaction = null) { m_selectremotevolumeCommand.Transaction = transaction; m_selectremotevolumeCommand.SetParameterValue(0, file); @@ -380,8 +352,8 @@ namespace Duplicati.Library.Main.Database rd.ConvertValueToInt64(3, -1), (RemoteVolumeType)Enum.Parse(typeof(RemoteVolumeType), rd.GetValue(2).ToString()), (RemoteVolumeState)Enum.Parse(typeof(RemoteVolumeState), rd.GetValue(5).ToString()), - new DateTime(rd.ConvertValueToInt64(6, 0), DateTimeKind.Utc), - new DateTime(rd.ConvertValueToInt64(7, 0), DateTimeKind.Utc) + ParseFromEpochSeconds(rd.ConvertValueToInt64(6, 0)), + ParseFromEpochSeconds(rd.ConvertValueToInt64(7, 0)) ); return RemoteVolumeEntry.Empty; @@ -398,7 +370,7 @@ namespace Duplicati.Library.Main.Database } } - public IEnumerable GetRemoteVolumes(System.Data.IDbTransaction transaction = null) + public IEnumerable GetRemoteVolumes(IDbTransaction transaction = null) { m_selectremotevolumesCommand.Transaction = transaction; using (var rd = m_selectremotevolumesCommand.ExecuteReader()) @@ -412,8 +384,8 @@ namespace Duplicati.Library.Main.Database rd.ConvertValueToInt64(3, -1), (RemoteVolumeType)Enum.Parse(typeof(RemoteVolumeType), rd.GetValue(2).ToString()), (RemoteVolumeState)Enum.Parse(typeof(RemoteVolumeState), rd.GetValue(5).ToString()), - new DateTime(rd.ConvertValueToInt64(6, 0), DateTimeKind.Utc), - new DateTime(rd.ConvertValueToInt64(7, 0), DateTimeKind.Utc) + ParseFromEpochSeconds(rd.ConvertValueToInt64(6, 0)), + ParseFromEpochSeconds(rd.ConvertValueToInt64(7, 0)) ); } } @@ -425,7 +397,7 @@ namespace Duplicati.Library.Main.Database /// The operation performed /// The path involved /// Any data relating to the operation - public void LogRemoteOperation(string operation, string path, string data, System.Data.IDbTransaction transaction) + public void LogRemoteOperation(string operation, string path, string data, IDbTransaction transaction) { m_insertremotelogCommand.Transaction = transaction; m_insertremotelogCommand.SetParameterValue(0, m_operationid); @@ -442,7 +414,7 @@ namespace Duplicati.Library.Main.Database /// The message type /// The message /// An optional exception - public void LogMessage(string type, string message, Exception exception, System.Data.IDbTransaction transaction) + public void LogMessage(string type, string message, Exception exception, IDbTransaction transaction) { m_insertlogCommand.Transaction = transaction; m_insertlogCommand.SetParameterValue(0, m_operationid); @@ -453,41 +425,38 @@ namespace Duplicati.Library.Main.Database m_insertlogCommand.ExecuteNonQuery(); } - public void UnlinkRemoteVolume(string name, RemoteVolumeState state, System.Data.IDbTransaction transaction = null) + public void UnlinkRemoteVolume(string name, RemoteVolumeState state, IDbTransaction transaction = null) { using (var tr = new TemporaryTransactionWrapper(m_connection, transaction)) - using (var cmd = m_connection.CreateCommand()) + using (var cmd = m_connection.CreateCommand(tr.Parent)) { - cmd.Transaction = tr.Parent; var c = cmd.ExecuteNonQuery(@"DELETE FROM ""RemoteVolume"" WHERE ""Name"" = ? AND ""State"" = ? ", name, state.ToString()); if (c != 1) - throw new Exception(string.Format("Unexpected number of remote volumes deleted: {0}, expected {1}", c, 1)); + throw new Exception($"Unexpected number of remote volumes deleted: {c}, expected {1}"); tr.Commit(); } } - public void RemoveRemoteVolume(string name, System.Data.IDbTransaction transaction = null) + public void RemoveRemoteVolume(string name, IDbTransaction transaction = null) { - RemoveRemoteVolumes(new string[] { name }, transaction); + RemoveRemoteVolumes([name], transaction); } - public void RemoveRemoteVolumes(IEnumerable names, System.Data.IDbTransaction transaction = null) + public void RemoveRemoteVolumes(IEnumerable names, IDbTransaction transaction = null) { if (names == null || !names.Any()) return; using (var tr = new TemporaryTransactionWrapper(m_connection, transaction)) - using (var deletecmd = m_connection.CreateCommand()) + using (var deletecmd = m_connection.CreateCommand(tr.Parent)) { - deletecmd.Transaction = tr.Parent; - string temptransguid = Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); var volidstable = "DelVolSetIds-" + temptransguid; var blocksetidstable = "DelBlockSetIds-" + temptransguid; // Create and fill a temp table with the volids to delete. We avoid using too many parameters that way. - deletecmd.ExecuteNonQuery(string.Format(@"CREATE TEMP TABLE ""{0}"" (""ID"" INTEGER PRIMARY KEY)", volidstable)); - deletecmd.CommandText = string.Format(@"INSERT OR IGNORE INTO ""{0}"" (""ID"") VALUES (?)", volidstable); + deletecmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMP TABLE ""{volidstable}"" (""ID"" INTEGER PRIMARY KEY)")); + deletecmd.CommandText = FormatInvariant($@"INSERT OR IGNORE INTO ""{volidstable}"" (""ID"") VALUES (?)"); deletecmd.Parameters.Clear(); deletecmd.AddParameters(1); foreach (var name in names) @@ -496,82 +465,81 @@ namespace Duplicati.Library.Main.Database deletecmd.SetParameterValue(0, volumeid); deletecmd.ExecuteNonQuery(); } - var volIdsSubQuery = string.Format(@"SELECT ""ID"" FROM ""{0}"" ", volidstable); + var volIdsSubQuery = FormatInvariant($@"SELECT ""ID"" FROM ""{volidstable}"" "); deletecmd.Parameters.Clear(); - var bsIdsSubQuery = string.Format( - @"SELECT DISTINCT ""BlocksetEntry"".""BlocksetID"" FROM ""BlocksetEntry"", ""Block""" - + @" WHERE ""BlocksetEntry"".""BlockID"" = ""Block"".""ID"" AND ""Block"".""VolumeID"" IN ({0}) " - + @"UNION ALL " - + @"SELECT DISTINCT ""BlocksetID"" FROM ""BlocklistHash""" - + @" WHERE ""Hash"" IN (SELECT ""Hash"" FROM ""Block"" WHERE ""VolumeID"" IN ({0}))" - , volIdsSubQuery); + var bsIdsSubQuery = FormatInvariant(@$" +SELECT DISTINCT ""BlocksetEntry"".""BlocksetID"" FROM ""BlocksetEntry"", ""Block"" +WHERE ""BlocksetEntry"".""BlockID"" = ""Block"".""ID"" AND ""Block"".""VolumeID"" IN ({volIdsSubQuery}) +UNION ALL +SELECT DISTINCT ""BlocksetID"" FROM ""BlocklistHash"" +WHERE ""Hash"" IN (SELECT ""Hash"" FROM ""Block"" WHERE ""VolumeID"" IN ({volIdsSubQuery}))"); // Create a temporary table to cache subquery result, as it might take long (SQLite does not cache at all). - deletecmd.ExecuteNonQuery(string.Format(@"CREATE TEMP TABLE ""{0}"" (""ID"" INTEGER PRIMARY KEY)", blocksetidstable)); - deletecmd.ExecuteNonQuery(string.Format(@"INSERT OR IGNORE INTO ""{0}"" (""ID"") {1}", blocksetidstable, bsIdsSubQuery)); - bsIdsSubQuery = string.Format(@"SELECT DISTINCT ""ID"" FROM ""{0}"" ", blocksetidstable); + deletecmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMP TABLE ""{blocksetidstable}"" (""ID"" INTEGER PRIMARY KEY)")); + deletecmd.ExecuteNonQuery(FormatInvariant($@"INSERT OR IGNORE INTO ""{blocksetidstable}"" (""ID"") {bsIdsSubQuery}")); + bsIdsSubQuery = FormatInvariant($@"SELECT DISTINCT ""ID"" FROM ""{blocksetidstable}"" "); deletecmd.Parameters.Clear(); // Create a temp table to associate metadata that is being deleted to a fileset - var metadataFilesetQuery = $@"SELECT Metadataset.ID, FilesetEntry.FilesetID + var metadataFilesetQuery = FormatInvariant($@"SELECT Metadataset.ID, FilesetEntry.FilesetID FROM Metadataset INNER JOIN FileLookup ON FileLookup.MetadataID = Metadataset.ID INNER JOIN FilesetEntry ON FilesetEntry.FileID = FileLookup.ID WHERE Metadataset.BlocksetID IN ({bsIdsSubQuery}) -OR Metadataset.ID IN (SELECT MetadataID FROM FileLookup WHERE BlocksetID IN ({bsIdsSubQuery}))"; +OR Metadataset.ID IN (SELECT MetadataID FROM FileLookup WHERE BlocksetID IN ({bsIdsSubQuery}))"); var metadataFilesetTable = @"DelMetadataFilesetIds-" + temptransguid; - deletecmd.ExecuteNonQuery($@"CREATE TEMP TABLE ""{metadataFilesetTable}"" (MetadataID INTEGER PRIMARY KEY, FilesetID INTEGER)"); - deletecmd.ExecuteNonQuery($@"INSERT OR IGNORE INTO ""{metadataFilesetTable}"" (MetadataID, FilesetID) {metadataFilesetQuery}"); + deletecmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMP TABLE ""{metadataFilesetTable}"" (MetadataID INTEGER PRIMARY KEY, FilesetID INTEGER)")); + deletecmd.ExecuteNonQuery(FormatInvariant($@"INSERT OR IGNORE INTO ""{metadataFilesetTable}"" (MetadataID, FilesetID) {metadataFilesetQuery}")); // Delete FilesetEntry rows that had their metadata deleted - deletecmd.ExecuteNonQuery($@"DELETE FROM FilesetEntry + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM FilesetEntry WHERE FilesetEntry.FilesetID IN (SELECT DISTINCT FilesetID FROM ""{metadataFilesetTable}"") AND FilesetEntry.FileID IN ( SELECT FilesetEntry.FileID FROM FilesetEntry INNER JOIN FileLookup ON FileLookup.ID = FilesetEntry.FileID - WHERE FileLookup.MetadataID IN (SELECT MetadataID FROM ""{metadataFilesetTable}""))"); + WHERE FileLookup.MetadataID IN (SELECT MetadataID FROM ""{metadataFilesetTable}""))")); // Delete FilesetEntry rows that had their blocks deleted - deletecmd.ExecuteNonQuery($@"DELETE FROM FilesetEntry WHERE FilesetEntry.FileID IN ( + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM FilesetEntry WHERE FilesetEntry.FileID IN ( SELECT ID FROM FileLookup -WHERE FileLookup.BlocksetID IN ({bsIdsSubQuery}))"); - deletecmd.ExecuteNonQuery($@"DELETE FROM FileLookup WHERE FileLookup.MetadataID IN (SELECT MetadataID FROM ""{metadataFilesetTable}"")"); +WHERE FileLookup.BlocksetID IN ({bsIdsSubQuery}))")); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM FileLookup WHERE FileLookup.MetadataID IN (SELECT MetadataID FROM ""{metadataFilesetTable}"")")); - deletecmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""Metadataset"" WHERE ""BlocksetID"" IN ({0})", bsIdsSubQuery)); - deletecmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""FileLookup"" WHERE ""BlocksetID"" IN ({0})", bsIdsSubQuery)); - deletecmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""Blockset"" WHERE ""ID"" IN ({0})", bsIdsSubQuery)); - deletecmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""BlocksetEntry"" WHERE ""BlocksetID"" IN ({0})", bsIdsSubQuery)); - deletecmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""BlocklistHash"" WHERE ""BlocklistHash"".""BlocksetID"" IN ({0})", bsIdsSubQuery)); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""Metadataset"" WHERE ""BlocksetID"" IN ({bsIdsSubQuery})")); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""FileLookup"" WHERE ""BlocksetID"" IN ({bsIdsSubQuery})")); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""Blockset"" WHERE ""ID"" IN ({bsIdsSubQuery})")); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""BlocksetEntry"" WHERE ""BlocksetID"" IN ({bsIdsSubQuery})")); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""BlocklistHash"" WHERE ""BlocklistHash"".""BlocksetID"" IN ({bsIdsSubQuery})")); // If the volume is a block or index volume, this will update the crosslink table, otherwise nothing will happen - deletecmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""IndexBlockLink"" WHERE ""BlockVolumeID"" IN ({0}) OR ""IndexVolumeID"" IN ({0})", volIdsSubQuery)); - deletecmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""Block"" WHERE ""VolumeID"" IN ({0})", volIdsSubQuery)); - deletecmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""DeletedBlock"" WHERE ""VolumeID"" IN ({0})", volIdsSubQuery)); - deletecmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""ChangeJournalData"" WHERE ""FilesetID"" IN (SELECT ""ID"" FROM ""Fileset"" WHERE ""VolumeID"" IN ({0}))", volIdsSubQuery)); - deletecmd.ExecuteNonQuery($@"DELETE FROM FilesetEntry WHERE FilesetID IN (SELECT ID FROM Fileset WHERE VolumeID IN ({volIdsSubQuery}))"); - deletecmd.ExecuteNonQuery($@"DELETE FROM Fileset WHERE VolumeID IN ({volIdsSubQuery})"); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""IndexBlockLink"" WHERE ""BlockVolumeID"" IN ({volIdsSubQuery}) OR ""IndexVolumeID"" IN ({volIdsSubQuery})")); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""Block"" WHERE ""VolumeID"" IN ({volIdsSubQuery})")); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""DeletedBlock"" WHERE ""VolumeID"" IN ({volIdsSubQuery})")); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""ChangeJournalData"" WHERE ""FilesetID"" IN (SELECT ""ID"" FROM ""Fileset"" WHERE ""VolumeID"" IN ({volIdsSubQuery}))")); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM FilesetEntry WHERE FilesetID IN (SELECT ID FROM Fileset WHERE VolumeID IN ({volIdsSubQuery}))")); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM Fileset WHERE VolumeID IN ({volIdsSubQuery})")); // Delete from Fileset if FilesetEntry rows were deleted by related metadata and there are no references in FilesetEntry anymore - deletecmd.ExecuteNonQuery($@"DELETE FROM Fileset WHERE Fileset.ID IN + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM Fileset WHERE Fileset.ID IN (SELECT DISTINCT FilesetID FROM ""{metadataFilesetTable}"") AND Fileset.ID NOT IN - (SELECT DISTINCT FilesetID FROM FilesetEntry)"); + (SELECT DISTINCT FilesetID FROM FilesetEntry)")); // Clean up temp tables for subqueries. We truncate content and then try to delete. // Drop in try-block, as it fails in nested transactions (SQLite problem) - // System.Data.SQLite.SQLiteException (0x80004005): database table is locked - deletecmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""{0}"" ", blocksetidstable)); - deletecmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""{0}"" ", volidstable)); + // SQLite.SQLiteException (0x80004005): database table is locked + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""{blocksetidstable}"" ")); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""{volidstable}"" ")); try { deletecmd.CommandTimeout = 2; - deletecmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"" ", blocksetidstable)); - deletecmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"" ", volidstable)); - deletecmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"" ", metadataFilesetTable)); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{blocksetidstable}"" ")); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{volidstable}"" ")); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{metadataFilesetTable}"" ")); } catch { /* Ignore, will be deleted on close anyway. */ } @@ -600,17 +568,17 @@ AND Fileset.ID NOT IN return RegisterRemoteVolume(name, type, state, size, new TimeSpan(0), null); } - public long RegisterRemoteVolume(string name, RemoteVolumeType type, RemoteVolumeState state, System.Data.IDbTransaction transaction) + public long RegisterRemoteVolume(string name, RemoteVolumeType type, RemoteVolumeState state, IDbTransaction transaction) { return RegisterRemoteVolume(name, type, state, new TimeSpan(0), transaction); } - public long RegisterRemoteVolume(string name, RemoteVolumeType type, RemoteVolumeState state, TimeSpan deleteGraceTime, System.Data.IDbTransaction transaction) + public long RegisterRemoteVolume(string name, RemoteVolumeType type, RemoteVolumeState state, TimeSpan deleteGraceTime, IDbTransaction transaction) { return RegisterRemoteVolume(name, type, state, -1, deleteGraceTime, transaction); } - public long RegisterRemoteVolume(string name, RemoteVolumeType type, RemoteVolumeState state, long size, TimeSpan deleteGraceTime, System.Data.IDbTransaction transaction) + public long RegisterRemoteVolume(string name, RemoteVolumeType type, RemoteVolumeState state, long size, TimeSpan deleteGraceTime, IDbTransaction transaction) { using (var tr = new TemporaryTransactionWrapper(m_connection, transaction)) { @@ -687,31 +655,27 @@ AND Fileset.ID NOT IN public bool IsFilesetFullBackup(DateTime filesetTime) { using (var cmd = m_connection.CreateCommand()) + using (var rd = cmd.ExecuteReader(FormatInvariant($@"SELECT ""IsFullBackup"" FROM ""Fileset"" WHERE ""Timestamp"" = {Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(filesetTime)}"))) { - using (var rd = cmd.ExecuteReader($@"SELECT ""IsFullBackup"" FROM ""Fileset"" WHERE ""Timestamp"" = {Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(filesetTime)}")) - { - if (!rd.Read()) - { - return false; - } - var isFullBackup = rd.GetInt32(0); - return isFullBackup == BackupType.FULL_BACKUP; - } + if (!rd.Read()) + return false; + var isFullBackup = rd.GetInt32(0); + return isFullBackup == BackupType.FULL_BACKUP; } } // TODO: Remove this - public System.Data.IDbTransaction BeginTransaction() + public IDbTransaction BeginTransaction() { return m_connection.BeginTransaction(); } protected class TemporaryTransactionWrapper : IDisposable { - private readonly System.Data.IDbTransaction m_parent; + private readonly IDbTransaction m_parent; private readonly bool m_isTemporary; - public TemporaryTransactionWrapper(System.Data.IDbConnection connection, System.Data.IDbTransaction transaction) + public TemporaryTransactionWrapper(IDbConnection connection, IDbTransaction transaction) { if (transaction != null) { @@ -737,10 +701,10 @@ AND Fileset.ID NOT IN m_parent.Dispose(); } - public System.Data.IDbTransaction Parent { get { return m_parent; } } + public IDbTransaction Parent { get { return m_parent; } } } - private IEnumerable> GetDbOptionList(System.Data.IDbTransaction transaction = null) + private IEnumerable> GetDbOptionList(IDbTransaction transaction = null) { using (var cmd = m_connection.CreateCommand(transaction)) using (var rd = cmd.ExecuteReader(@"SELECT ""Key"", ""Value"" FROM ""Configuration"" ")) @@ -748,55 +712,65 @@ AND Fileset.ID NOT IN yield return new KeyValuePair(rd.GetValue(0).ToString(), rd.GetValue(1).ToString()); } - public IDictionary GetDbOptions(System.Data.IDbTransaction transaction = null) + public IDictionary GetDbOptions(IDbTransaction transaction = null) { return GetDbOptionList(transaction).ToDictionary(x => x.Key, x => x.Value); } + /// + /// Updates a database option + /// + /// The key to update + /// The value to set + private void UpdateDbOption(string key, bool value) + { + var opts = GetDbOptions(); + + if (value) + opts[key] = "true"; + else + opts.Remove(key); + + SetDbOptions(opts); + } + + /// + /// Flag indicating if a repair is in progress + /// public bool RepairInProgress { - get - { - return GetDbOptions().ContainsKey("repair-in-progress"); - } - set - { - var opts = GetDbOptions(); - - if (value) - opts["repair-in-progress"] = "true"; - else - opts.Remove("repair-in-progress"); - - SetDbOptions(opts); - } + get => GetDbOptions().ContainsKey("repair-in-progress"); + set => UpdateDbOption("repair-in-progress", value); } + /// + /// Flag indicating if a repair is in progress + /// public bool PartiallyRecreated { - get - { - return GetDbOptions().ContainsKey("partially-recreated"); - } - set - { - var opts = GetDbOptions(); - - if (value) - opts["partially-recreated"] = "true"; - else - opts.Remove("partially-recreated"); - - SetDbOptions(opts); - } + get => GetDbOptions().ContainsKey("partially-recreated"); + set => UpdateDbOption("partially-recreated", value); } - public void SetDbOptions(IDictionary options, System.Data.IDbTransaction transaction = null) + /// + /// Flag indicating if the database can contain partial uploads + /// + public bool TerminatedWithActiveUploads + { + get => GetDbOptions().ContainsKey("terminated-with-active-uploads"); + set => UpdateDbOption("terminated-with-active-uploads", value); + } + + /// + /// Sets the database options + /// + /// The options to set + /// An optional transaction + public void SetDbOptions(IDictionary options, IDbTransaction transaction = null) { using (var tr = new TemporaryTransactionWrapper(m_connection, transaction)) - using (var cmd = m_connection.CreateCommand()) + using (var cmd = m_connection.CreateCommand(tr.Parent)) { - cmd.Transaction = tr.Parent; cmd.ExecuteNonQuery(@"DELETE FROM ""Configuration"" "); foreach (var kp in options) cmd.ExecuteNonQuery(@"INSERT INTO ""Configuration"" (""Key"", ""Value"") VALUES (?, ?) ", kp.Key, kp.Value); @@ -811,7 +785,7 @@ AND Fileset.ID NOT IN return cmd.ExecuteScalarInt64(@"SELECT COUNT(*) FROM ""Block"" WHERE ""Size"" > ?", -1, fhblocksize); } - public void VerifyConsistency(long blocksize, long hashsize, bool verifyfilelists, System.Data.IDbTransaction transaction) + public void VerifyConsistency(long blocksize, long hashsize, bool verifyfilelists, IDbTransaction transaction) { using (var cmd = m_connection.CreateCommand(transaction)) { @@ -861,36 +835,61 @@ ON sb.AppendFormat("... and {0} more", c); sb.Append(". Run repair to fix it."); - throw new InvalidDataException(sb.ToString()); + throw new DatabaseInconsistencyException(sb.ToString()); } var real_count = cmd.ExecuteScalarInt64(@"SELECT Count(*) FROM ""BlocklistHash""", 0); var unique_count = cmd.ExecuteScalarInt64(@"SELECT Count(*) FROM (SELECT DISTINCT ""BlocksetID"", ""Index"" FROM ""BlocklistHash"")", 0); if (real_count != unique_count) - throw new InvalidDataException(string.Format("Found {0} blocklist hashes, but there should be {1}. Run repair to fix it.", real_count, unique_count)); + throw new DatabaseInconsistencyException($"Found {real_count} blocklist hashes, but there should be {unique_count}. Run repair to fix it."); - var itemswithnoblocklisthash = cmd.ExecuteScalarInt64(string.Format(@"SELECT COUNT(*) FROM (SELECT * FROM (SELECT ""N"".""BlocksetID"", ((""N"".""BlockCount"" + {0} - 1) / {0}) AS ""BlocklistHashCountExpected"", CASE WHEN ""G"".""BlocklistHashCount"" IS NULL THEN 0 ELSE ""G"".""BlocklistHashCount"" END AS ""BlocklistHashCountActual"" FROM (SELECT ""BlocksetID"", COUNT(*) AS ""BlockCount"" FROM ""BlocksetEntry"" GROUP BY ""BlocksetID"") ""N"" LEFT OUTER JOIN (SELECT ""BlocksetID"", COUNT(*) AS ""BlocklistHashCount"" FROM ""BlocklistHash"" GROUP BY ""BlocksetID"") ""G"" ON ""N"".""BlocksetID"" = ""G"".""BlocksetID"" WHERE ""N"".""BlockCount"" > 1) WHERE ""BlocklistHashCountExpected"" != ""BlocklistHashCountActual"")", blocksize / hashsize), 0); + var itemswithnoblocklisthash = cmd.ExecuteScalarInt64(FormatInvariant($@"SELECT COUNT(*) FROM (SELECT * FROM (SELECT ""N"".""BlocksetID"", ((""N"".""BlockCount"" + {blocksize / hashsize} - 1) / {blocksize / hashsize}) AS ""BlocklistHashCountExpected"", CASE WHEN ""G"".""BlocklistHashCount"" IS NULL THEN 0 ELSE ""G"".""BlocklistHashCount"" END AS ""BlocklistHashCountActual"" FROM (SELECT ""BlocksetID"", COUNT(*) AS ""BlockCount"" FROM ""BlocksetEntry"" GROUP BY ""BlocksetID"") ""N"" LEFT OUTER JOIN (SELECT ""BlocksetID"", COUNT(*) AS ""BlocklistHashCount"" FROM ""BlocklistHash"" GROUP BY ""BlocksetID"") ""G"" ON ""N"".""BlocksetID"" = ""G"".""BlocksetID"" WHERE ""N"".""BlockCount"" > 1) WHERE ""BlocklistHashCountExpected"" != ""BlocklistHashCountActual"")"), 0); if (itemswithnoblocklisthash != 0) - throw new InvalidDataException(string.Format("Found {0} file(s) with missing blocklist hashes", itemswithnoblocklisthash)); + throw new DatabaseInconsistencyException($"Found {itemswithnoblocklisthash} file(s) with missing blocklist hashes"); if (cmd.ExecuteScalarInt64(@"SELECT COUNT(*) FROM ""Blockset"" WHERE ""Length"" > 0 AND ""ID"" NOT IN (SELECT ""BlocksetId"" FROM ""BlocksetEntry"")") != 0) - { - throw new Exception("Detected non-empty blocksets with no associated blocks!"); - } + throw new DatabaseInconsistencyException("Detected non-empty blocksets with no associated blocks!"); if (cmd.ExecuteScalarInt64(@"SELECT COUNT(*) FROM ""FileLookup"" WHERE ""BlocksetID"" != ? AND ""BlocksetID"" != ? AND NOT ""BlocksetID"" IN (SELECT ""ID"" FROM ""Blockset"")", 0, FOLDER_BLOCKSET_ID, SYMLINK_BLOCKSET_ID) != 0) - throw new Exception("Detected files associated with non-existing blocksets!"); + throw new DatabaseInconsistencyException("Detected files associated with non-existing blocksets!"); + + var filesetsMissingVolumes = cmd.ExecuteScalarInt64(@"SELECT COUNT(*) FROM ""Fileset"" WHERE ""VolumeID"" NOT IN (SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Type"" = ? AND ""State"" != ?)", 0, RemoteVolumeType.Files.ToString(), RemoteVolumeState.Deleted.ToString()); + if (filesetsMissingVolumes != 0) + { + if (filesetsMissingVolumes == 1) + using (var reader = cmd.ExecuteReader(@"SELECT ""ID"", ""Timestamp"", ""VolumeID"" FROM ""Fileset"" WHERE ""VolumeID"" NOT IN (SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Type"" = ? AND ""State"" != ?)", 0, RemoteVolumeType.Files.ToString(), RemoteVolumeState.Deleted.ToString())) + if (reader.Read()) + throw new DatabaseInconsistencyException($"Detected 1 fileset with missing volume: FilesetId = {reader.ConvertValueToInt64(0)}, Time = ({ParseFromEpochSeconds(reader.ConvertValueToInt64(1))}), unmatched VolumeID {reader.ConvertValueToInt64(2)}"); + + throw new DatabaseInconsistencyException($"Detected {filesetsMissingVolumes} filesets with missing volumes"); + } + + var volumesMissingFilests = cmd.ExecuteScalarInt64(@"SELECT COUNT(*) FROM ""RemoteVolume"" WHERE ""Type"" = ? AND ""State"" != ? AND ""ID"" NOT IN (SELECT ""VolumeID"" FROM ""Fileset"")", 0, RemoteVolumeType.Files.ToString(), RemoteVolumeState.Deleted.ToString()); + if (volumesMissingFilests != 0) + { + if (volumesMissingFilests == 1) + using (var reader = cmd.ExecuteReader(@"SELECT ""ID"", ""Name"", ""State"" FROM ""RemoteVolume"" WHERE ""Type"" = ? AND ""State"" != ? AND ""ID"" NOT IN (SELECT ""VolumeID"" FROM ""Fileset"")", 0, RemoteVolumeType.Files.ToString(), RemoteVolumeState.Deleted.ToString())) + if (reader.Read()) + throw new DatabaseInconsistencyException($"Detected 1 volume with missing filesets: VolumeId = {reader.ConvertValueToInt64(0)}, Name = {reader.ConvertValueToString(1)}, State = {reader.ConvertValueToString(2)}"); + + throw new DatabaseInconsistencyException($"Detected {volumesMissingFilests} volumes with missing filesets"); + } + + var nonAttachedFiles = cmd.ExecuteScalarInt64(@"SELECT COUNT(*) FROM ""FilesetEntry"" WHERE ""FileID"" NOT IN (SELECT ""ID"" FROM ""FileLookup"")"); + if (nonAttachedFiles != 0) + throw new DatabaseInconsistencyException($"Detected {nonAttachedFiles} file(s) in FilesetEntry without corresponding FileLookup entry"); if (verifyfilelists) { var anyError = new List(); using (var cmd2 = m_connection.CreateCommand(transaction)) + { foreach (var filesetid in cmd.ExecuteReaderEnumerable(@"SELECT ""ID"" FROM ""Fileset"" ").Select(x => x.ConvertValueToInt64(0, -1))) { - var expandedCmd = string.Format(@"SELECT COUNT(*) FROM (SELECT DISTINCT ""Path"" FROM ({0}) UNION SELECT DISTINCT ""Path"" FROM ({1}))", LocalDatabase.LIST_FILESETS, LocalDatabase.LIST_FOLDERS_AND_SYMLINKS); + var expandedCmd = FormatInvariant($@"SELECT COUNT(*) FROM (SELECT DISTINCT ""Path"" FROM ({LocalDatabase.LIST_FILESETS}) UNION SELECT DISTINCT ""Path"" FROM ({LocalDatabase.LIST_FOLDERS_AND_SYMLINKS}))"); var expandedlist = cmd2.ExecuteScalarInt64(expandedCmd, 0, filesetid, FOLDER_BLOCKSET_ID, SYMLINK_BLOCKSET_ID, filesetid); - //var storedfilelist = cmd2.ExecuteScalarInt64(string.Format(@"SELECT COUNT(*) FROM ""FilesetEntry"", ""FileLookup"" WHERE ""FilesetEntry"".""FilesetID"" = ? AND ""FileLookup"".""ID"" = ""FilesetEntry"".""FileID"" AND ""FileLookup"".""BlocksetID"" != ? AND ""FileLookup"".""BlocksetID"" != ?"), 0, filesetid, FOLDER_BLOCKSET_ID, SYMLINK_BLOCKSET_ID); + //var storedfilelist = cmd2.ExecuteScalarInt64(FormatInvariant(@"SELECT COUNT(*) FROM ""FilesetEntry"", ""FileLookup"" WHERE ""FilesetEntry"".""FilesetID"" = ? AND ""FileLookup"".""ID"" = ""FilesetEntry"".""FileID"" AND ""FileLookup"".""BlocksetID"" != ? AND ""FileLookup"".""BlocksetID"" != ?"), 0, filesetid, FOLDER_BLOCKSET_ID, SYMLINK_BLOCKSET_ID); var storedlist = cmd2.ExecuteScalarInt64(@"SELECT COUNT(*) FROM ""FilesetEntry"" WHERE ""FilesetEntry"".""FilesetID"" = ?", 0, filesetid); if (expandedlist != storedlist) @@ -898,13 +897,14 @@ ON var filesetname = filesetid.ToString(); var fileset = FilesetTimes.Zip(Enumerable.Range(0, FilesetTimes.Count()), (a, b) => new Tuple(b, a.Key, a.Value)).FirstOrDefault(x => x.Item2 == filesetid); if (fileset != null) - filesetname = string.Format("version {0}: {1} (database id: {2})", fileset.Item1, fileset.Item3, fileset.Item2); - anyError.Add(string.Format("Unexpected difference in fileset {0}, found {1} entries, but expected {2}", filesetname, expandedlist, storedlist)); + filesetname = $"version {fileset.Item1}: {fileset.Item3} (database id: {fileset.Item2})"; + anyError.Add($"Unexpected difference in fileset {filesetname}, found {expandedlist} entries, but expected {storedlist}"); } } + } if (anyError.Any()) { - throw new Interface.UserInformationException(string.Join("\n\r", anyError), "FilesetDifferences"); + throw new DatabaseInconsistencyException(string.Join("\n\r", anyError), "FilesetDifferences"); } } } @@ -923,12 +923,12 @@ ON public Block(string hash, long size) { - this.Hash = hash; - this.Size = size; + Hash = hash; + Size = size; } } - public IEnumerable GetBlocks(long volumeid, System.Data.IDbTransaction transaction = null) + public IEnumerable GetBlocks(long volumeid, IDbTransaction transaction = null) { using (var cmd = m_connection.CreateCommand(transaction)) using (var rd = cmd.ExecuteReader(@"SELECT DISTINCT ""Hash"", ""Size"" FROM ""Block"" WHERE ""VolumeID"" = ?", volumeid)) @@ -940,13 +940,13 @@ ON { private class BlocklistHashEnumerator : IEnumerator { - private readonly System.Data.IDataReader m_reader; + private readonly IDataReader m_reader; private readonly BlocklistHashEnumerable m_parent; private string m_path = null; private bool m_first = true; private string m_current = null; - public BlocklistHashEnumerator(BlocklistHashEnumerable parent, System.Data.IDataReader reader) + public BlocklistHashEnumerator(BlocklistHashEnumerable parent, IDataReader reader) { m_reader = reader; m_parent = parent; @@ -958,7 +958,7 @@ ON { } - object System.Collections.IEnumerator.Current { get { return this.Current; } } + object System.Collections.IEnumerator.Current { get { return Current; } } public bool MoveNext() { @@ -1003,12 +1003,12 @@ ON } } - private readonly System.Data.IDataReader m_reader; + private readonly IDataReader m_reader; - public BlocklistHashEnumerable(System.Data.IDataReader reader) + public BlocklistHashEnumerable(IDataReader reader) { m_reader = reader; - this.MoreData = true; + MoreData = true; } public bool MoreData { get; protected set; } @@ -1020,7 +1020,7 @@ ON System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { - return this.GetEnumerator(); + return GetEnumerator(); } } @@ -1152,11 +1152,10 @@ ORDER BY "; - public void WriteFileset(Volumes.FilesetVolumeWriter filesetvolume, long filesetId, System.Data.IDbTransaction transaction) + public void WriteFileset(Volumes.FilesetVolumeWriter filesetvolume, long filesetId, IDbTransaction transaction) { - using (var cmd = m_connection.CreateCommand()) + using (var cmd = m_connection.CreateCommand(transaction)) { - cmd.Transaction = transaction; cmd.CommandText = LIST_FOLDERS_AND_SYMLINKS; cmd.AddParameter(FOLDER_BLOCKSET_ID); cmd.AddParameter(SYMLINK_BLOCKSET_ID); @@ -1227,47 +1226,70 @@ ORDER BY } } + public void LinkFilesetToVolume(long filesetid, long volumeid, IDbTransaction transaction) + { + using (var cmd = m_connection.CreateCommand(transaction)) + { + var c = cmd.ExecuteNonQuery(@"UPDATE ""Fileset"" SET ""VolumeID"" = ? WHERE ""ID"" = ?", volumeid, filesetid); + if (c != 1) + throw new Exception($"Failed to link filesetid {filesetid} to volumeid {volumeid}"); + } + } + + public void PushTimestampChangesToPreviousVersion(long filesetId, IDbTransaction transaction) + { + using (var cmd = m_connection.CreateCommand(transaction)) + { + var query = @" +UPDATE FilesetEntry AS oldVersion +SET Lastmodified = tempVersion.Lastmodified +FROM FilesetEntry AS tempVersion +WHERE oldVersion.FileID = tempVersion.FileID +AND tempVersion.FilesetID = ? +AND oldVersion.FilesetID = (SELECT ID FROM Fileset WHERE ID != ? ORDER BY Timestamp DESC LIMIT 1)"; + + cmd.ExecuteNonQuery(query, filesetId, filesetId); + } + } + /// /// Keeps a list of filenames in a temporary table with a single column Path /// public class FilteredFilenameTable : IDisposable { public string Tablename { get; private set; } - private readonly System.Data.IDbConnection m_connection; + private readonly IDbConnection m_connection; - public FilteredFilenameTable(System.Data.IDbConnection connection, Library.Utility.IFilter filter, System.Data.IDbTransaction transaction) + public FilteredFilenameTable(IDbConnection connection, IFilter filter, IDbTransaction transaction) { m_connection = connection; Tablename = "Filenames-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); - var type = Library.Utility.FilterType.Regexp; + var type = FilterType.Regexp; if (filter is FilterExpression expression) type = expression.Type; // Bugfix: SQLite does not handle case-insensitive LIKE with non-ascii characters - if (type != Library.Utility.FilterType.Regexp && !Library.Utility.Utility.IsFSCaseSensitive && filter.ToString().Any(x => x > 127)) - type = Library.Utility.FilterType.Regexp; + if (type != FilterType.Regexp && !Library.Utility.Utility.IsFSCaseSensitive && filter.ToString().Any(x => x > 127)) + type = FilterType.Regexp; if (filter.Empty) { - using (var cmd = m_connection.CreateCommand()) + using (var cmd = m_connection.CreateCommand(transaction)) { - cmd.Transaction = transaction; - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" AS SELECT DISTINCT ""Path"" FROM ""File"" ", Tablename)); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{Tablename}"" AS SELECT DISTINCT ""Path"" FROM ""File"" ")); return; } } - if (type == Library.Utility.FilterType.Regexp || type == Library.Utility.FilterType.Group) + if (type == FilterType.Regexp || type == FilterType.Group) { - using (var cmd = m_connection.CreateCommand()) + using (var cmd = m_connection.CreateCommand(transaction)) { // TODO: Optimize this to not rely on the "File" view, and not instantiate the paths in full - - cmd.Transaction = transaction; - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" (""Path"" TEXT NOT NULL)", Tablename)); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{Tablename}"" (""Path"" TEXT NOT NULL)")); using (var tr = new TemporaryTransactionWrapper(m_connection, transaction)) { - cmd.CommandText = string.Format(@"INSERT INTO ""{0}"" (""Path"") VALUES (?)", Tablename); + cmd.CommandText = FormatInvariant($@"INSERT INTO ""{Tablename}"" (""Path"") VALUES (?)"); cmd.AddParameter(); cmd.Transaction = tr.Parent; using (var c2 = m_connection.CreateCommand()) @@ -1275,7 +1297,7 @@ ORDER BY while (rd.Read()) { var p = rd.GetValue(0).ToString(); - if (Library.Utility.FilterExpression.Matches(filter, p)) + if (FilterExpression.Matches(filter, p)) { cmd.SetParameterValue(0, p); cmd.ExecuteNonQuery(); @@ -1291,7 +1313,7 @@ ORDER BY { var sb = new StringBuilder(); var args = new List(); - foreach (var f in ((Library.Utility.FilterExpression)filter).GetSimpleList()) + foreach (var f in ((FilterExpression)filter).GetSimpleList()) { if (type == FilterType.Wildcard) { @@ -1307,12 +1329,11 @@ ORDER BY sb.Length = sb.Length - " OR ".Length; - using (var cmd = m_connection.CreateCommand()) using (var tr = new TemporaryTransactionWrapper(m_connection, transaction)) + using (var cmd = m_connection.CreateCommand(tr.Parent)) { - cmd.Transaction = tr.Parent; - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" (""Path"" TEXT NOT NULL)", Tablename)); - cmd.ExecuteNonQuery(string.Format(@"INSERT INTO ""{0}"" SELECT DISTINCT ""Path"" FROM ""File"" WHERE " + sb, Tablename), args.ToArray()); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{Tablename}"" (""Path"" TEXT NOT NULL)")); + cmd.ExecuteNonQuery(FormatInvariant($@"INSERT INTO ""{Tablename}"" SELECT DISTINCT ""Path"" FROM ""File"" WHERE {sb}"), args.ToArray()); tr.Commit(); } } @@ -1324,24 +1345,22 @@ ORDER BY try { using (var cmd = m_connection.CreateCommand()) - cmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"" ", Tablename)); + cmd.ExecuteNonQuery(FormatInvariant(@$"DROP TABLE IF EXISTS ""{Tablename}"" ")); } catch { } finally { Tablename = null; } } } - public void RenameRemoteFile(string oldname, string newname, System.Data.IDbTransaction transaction) + public void RenameRemoteFile(string oldname, string newname, IDbTransaction transaction) { using (var tr = new TemporaryTransactionWrapper(m_connection, transaction)) - using (var cmd = m_connection.CreateCommand()) + using (var cmd = m_connection.CreateCommand(tr.Parent)) { - cmd.Transaction = tr.Parent; - //Rename the old entry, to preserve ID links var c = cmd.ExecuteNonQuery(@"UPDATE ""Remotevolume"" SET ""Name"" = ? WHERE ""Name"" = ?", newname, oldname); if (c != 1) - throw new Exception(string.Format("Unexpected result from renaming \"{0}\" to \"{1}\", expected {2} got {3}", oldname, newname, 1, c)); + throw new Exception($"Unexpected result from renaming \"{oldname}\" to \"{newname}\", expected {1} got {c}"); // Grab the type of entry var type = (RemoteVolumeType)Enum.Parse(typeof(RemoteVolumeType), cmd.ExecuteScalar(@"SELECT ""Type"" FROM ""Remotevolume"" WHERE ""Name"" = ?", newname).ToString(), true); @@ -1360,19 +1379,18 @@ ORDER BY /// The ID of the fileset volume to update /// The timestamp of the operation to create /// An optional external transaction - public virtual long CreateFileset(long volumeid, DateTime timestamp, System.Data.IDbTransaction transaction = null) + public virtual long CreateFileset(long volumeid, DateTime timestamp, IDbTransaction transaction = null) { - using (var cmd = m_connection.CreateCommand()) using (var tr = new TemporaryTransactionWrapper(m_connection, transaction)) + using (var cmd = m_connection.CreateCommand(tr.Parent)) { - cmd.Transaction = tr.Parent; var id = cmd.ExecuteScalarInt64(@"INSERT INTO ""Fileset"" (""OperationID"", ""Timestamp"", ""VolumeID"", ""IsFullBackup"") VALUES (?, ?, ?, ?); SELECT last_insert_rowid();", -1, m_operationid, Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(timestamp), volumeid, BackupType.PARTIAL_BACKUP); tr.Commit(); return id; } } - public void AddIndexBlockLink(long indexVolumeID, long blockVolumeID, System.Data.IDbTransaction transaction) + public void AddIndexBlockLink(long indexVolumeID, long blockVolumeID, IDbTransaction transaction) { m_insertIndexBlockLink.Transaction = transaction; m_insertIndexBlockLink.SetParameterValue(0, indexVolumeID); @@ -1388,18 +1406,16 @@ ORDER BY /// The size of the hash /// An optional external transaction /// An enumerable of tuples containing the blocklist hash, the blocklist data and the length of the data - public IEnumerable> GetBlocklists(long volumeid, long blocksize, int hashsize, System.Data.IDbTransaction transaction = null) + public IEnumerable> GetBlocklists(long volumeid, long blocksize, int hashsize, IDbTransaction transaction = null) { using (var cmd = m_connection.CreateCommand(transaction)) { // Group subquery by hash to ensure that each blocklist hash appears only once in the result - var sql = string.Format(@"SELECT ""A"".""Hash"", ""C"".""Hash"" FROM " + - @"(SELECT ""BlocklistHash"".""BlocksetID"", ""Block"".""Hash"", ""BlocklistHash"".""Index"" FROM ""BlocklistHash"",""Block"" WHERE ""BlocklistHash"".""Hash"" = ""Block"".""Hash"" AND ""Block"".""VolumeID"" = ? GROUP BY ""Block"".""Hash"", ""Block"".""Size"") A, " + - @" ""BlocksetEntry"" B, ""Block"" C WHERE ""B"".""BlocksetID"" = ""A"".""BlocksetID"" AND " + - @" ""B"".""Index"" >= (""A"".""Index"" * {0}) AND ""B"".""Index"" < ((""A"".""Index"" + 1) * {0}) AND ""C"".""ID"" = ""B"".""BlockID"" " + - @" ORDER BY ""A"".""BlocksetID"", ""B"".""Index""", - blocksize / hashsize - ); + var sql = FormatInvariant($@"SELECT ""A"".""Hash"", ""C"".""Hash"" FROM +(SELECT ""BlocklistHash"".""BlocksetID"", ""Block"".""Hash"", ""BlocklistHash"".""Index"" FROM ""BlocklistHash"",""Block"" WHERE ""BlocklistHash"".""Hash"" = ""Block"".""Hash"" AND ""Block"".""VolumeID"" = ? GROUP BY ""Block"".""Hash"", ""Block"".""Size"") A, + ""BlocksetEntry"" B, ""Block"" C WHERE ""B"".""BlocksetID"" = ""A"".""BlocksetID"" AND + ""B"".""Index"" >= (""A"".""Index"" * {blocksize / hashsize}) AND ""B"".""Index"" < ((""A"".""Index"" + 1) * {blocksize / hashsize}) AND ""C"".""ID"" = ""B"".""BlockID"" + ORDER BY ""A"".""BlocksetID"", ""B"".""Index"""); string curHash = null; int count = 0; @@ -1436,17 +1452,70 @@ ORDER BY public void UpdateFullBackupStateInFileset(long fileSetId, bool isFullBackup, IDbTransaction transaction = null) { using (var tr = new TemporaryTransactionWrapper(m_connection, transaction)) + using (var cmd = m_connection.CreateCommand(tr.Parent)) { - using (var cmd = m_connection.CreateCommand()) - { - cmd.Transaction = tr.Parent; - cmd.ExecuteNonQuery(@"UPDATE ""Fileset"" SET ""IsFullBackup"" = ? WHERE ""ID"" = ?;", isFullBackup, fileSetId); - } - + cmd.ExecuteNonQuery(@"UPDATE ""Fileset"" SET ""IsFullBackup"" = ? WHERE ""ID"" = ?;", isFullBackup, fileSetId); tr.Commit(); } } + /// + /// Gets the last previous fileset that was incomplete + /// + /// The transaction to use + /// The last incomplete fileset or default + public RemoteVolumeEntry GetLastIncompleteFilesetVolume(IDbTransaction transaction) + { + var candidates = GetIncompleteFilesets(transaction).OrderBy(x => x.Value).ToArray(); + if (candidates.Any()) + return GetRemoteVolumeFromFilesetID(candidates.Last().Key, transaction); + + return default; + } + + /// + /// Gets a list of incomplete filesets + /// + /// An optional transaction + /// A list of fileset IDs and timestamps + public IEnumerable> GetIncompleteFilesets(IDbTransaction transaction) + { + using (var cmd = m_connection.CreateCommand(transaction)) + using (var rd = cmd.ExecuteReader(FormatInvariant(@$"SELECT DISTINCT ""Fileset"".""ID"", ""Fileset"".""Timestamp"" FROM ""Fileset"", ""RemoteVolume"" WHERE ""RemoteVolume"".""ID"" = ""Fileset"".""VolumeID"" AND ""Fileset"".""ID"" IN (SELECT ""FilesetID"" FROM ""FilesetEntry"") AND (""RemoteVolume"".""State"" = '{RemoteVolumeState.Uploading}' OR ""RemoteVolume"".""State"" = '{RemoteVolumeState.Temporary}')"))) + while (rd.Read()) + { + yield return new KeyValuePair( + rd.GetInt64(0), + ParseFromEpochSeconds(rd.GetInt64(1)).ToLocalTime() + ); + } + } + + /// + /// Gets the remote volume entry from the fileset ID + /// + /// The fileset ID + /// An optional transaction + /// The remote volume entry or default + public RemoteVolumeEntry GetRemoteVolumeFromFilesetID(long filesetID, IDbTransaction transaction = null) + { + using (var cmd = m_connection.CreateCommand(transaction)) + using (var rd = cmd.ExecuteReader(@"SELECT ""RemoteVolume"".""ID"", ""Name"", ""Type"", ""Size"", ""Hash"", ""State"", ""DeleteGraceTime"", ""ArchiveTime"" FROM ""RemoteVolume"", ""Fileset"" WHERE ""Fileset"".""VolumeID"" = ""RemoteVolume"".""ID"" AND ""Fileset"".""ID"" = ?", filesetID)) + if (rd.Read()) + return new RemoteVolumeEntry( + rd.ConvertValueToInt64(0, -1), + rd.GetValue(1).ToString(), + (rd.GetValue(4) == null || rd.GetValue(4) == DBNull.Value) ? null : rd.GetValue(4).ToString(), + rd.ConvertValueToInt64(3, -1), + (RemoteVolumeType)Enum.Parse(typeof(RemoteVolumeType), rd.GetValue(2).ToString()), + (RemoteVolumeState)Enum.Parse(typeof(RemoteVolumeState), rd.GetValue(5).ToString()), + new DateTime(rd.ConvertValueToInt64(6, 0), DateTimeKind.Utc), + new DateTime(rd.ConvertValueToInt64(7, 0), DateTimeKind.Utc) + ); + else + return default(RemoteVolumeEntry); + } + public void PurgeLogData(DateTime threshold) { using (var tr = m_connection.BeginTransaction()) @@ -1477,11 +1546,11 @@ ORDER BY if (IsDisposed) return; - DisposeAllFields(this, false); + DisposeAllFields(this, false); if (ShouldCloseConnection && m_connection != null) { - if (m_connection.State == System.Data.ConnectionState.Open && !m_hasExecutedVacuum) + if (m_connection.State == ConnectionState.Open && !m_hasExecutedVacuum) { using (var transaction = m_connection.BeginTransaction()) using (var command = m_connection.CreateCommand(transaction)) @@ -1493,7 +1562,7 @@ ORDER BY { transaction.Commit(); } - catch (System.Data.SQLite.SQLiteException ex) + catch (SQLite.SQLiteException ex) { Logging.Log.WriteVerboseMessage(LOGTAG, "FailedToCommitTransaction", ex, "Failed to commit transaction after pragma optimize, usually caused by the a no-op transaction"); } @@ -1584,7 +1653,7 @@ ORDER BY /// The path prefix ID. /// The path to get the prefix for. /// The transaction to use for insertion, or null for no transaction - public long GetOrCreatePathPrefix(string prefix, System.Data.IDbTransaction transaction) + public long GetOrCreatePathPrefix(string prefix, IDbTransaction transaction) { // Ring-buffer style lookup for (var i = 0; i < m_pathPrefixLookup.Length; i++) @@ -1614,8 +1683,8 @@ ORDER BY /// The path separators on this system /// private static readonly char[] _pathseparators = new char[] { - System.IO.Path.DirectorySeparatorChar, - System.IO.Path.AltDirectorySeparatorChar, + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar, }; /// diff --git a/Duplicati/Library/Main/Database/LocalDeleteDatabase.cs b/Duplicati/Library/Main/Database/LocalDeleteDatabase.cs index 2dfc6cb67..3bbcd8f68 100644 --- a/Duplicati/Library/Main/Database/LocalDeleteDatabase.cs +++ b/Duplicati/Library/Main/Database/LocalDeleteDatabase.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -39,7 +39,7 @@ namespace Duplicati.Library.Main.Database /// private const string TEMPORARY = "TEMPORARY"; - private System.Data.IDbCommand m_registerDuplicateBlockCommand; + private IDbCommand m_registerDuplicateBlockCommand; public LocalDeleteDatabase(string path, string operation) : base(path, operation, true) @@ -55,9 +55,7 @@ namespace Duplicati.Library.Main.Database private void InitializeCommands() { - m_registerDuplicateBlockCommand = m_connection.CreateCommand(); - m_registerDuplicateBlockCommand.CommandText = @"INSERT OR IGNORE INTO ""DuplicateBlock"" (""BlockID"", ""VolumeID"") SELECT ""ID"", ? FROM ""Block"" WHERE ""Hash"" = ? AND ""Size"" = ? "; - m_registerDuplicateBlockCommand.AddParameters(3); + m_registerDuplicateBlockCommand = m_connection.CreateCommand(@"INSERT OR IGNORE INTO ""DuplicateBlock"" (""BlockID"", ""VolumeID"") SELECT ""ID"", ? FROM ""Block"" WHERE ""Hash"" = ? AND ""Size"" = ? "); } /// @@ -66,29 +64,27 @@ namespace Duplicati.Library.Main.Database /// The fileset entries to delete /// The transaction to execute the commands in /// A list of filesets to delete - public IEnumerable> DropFilesetsFromTable(DateTime[] toDelete, System.Data.IDbTransaction transaction) + public IEnumerable> DropFilesetsFromTable(DateTime[] toDelete, IDbTransaction transaction) { - using (var cmd = m_connection.CreateCommand()) + using (var cmd = m_connection.CreateCommand(transaction)) { - cmd.Transaction = transaction; - var deleted = 0; //Process array in slices to prevent exceeding SQLITE_MAX_VARIABLE_NUMBER (default 999) const int SLICE_SIZE = 128; - for (int sliceStart = 0; sliceStart < toDelete.Length; sliceStart += SLICE_SIZE) + for (var sliceStart = 0; sliceStart < toDelete.Length; sliceStart += SLICE_SIZE) { - int sliceEnd = Math.Min(toDelete.Length, sliceStart + SLICE_SIZE) - 1; - int sliceLen = sliceEnd - sliceStart + 1; + var sliceEnd = Math.Min(toDelete.Length, sliceStart + SLICE_SIZE) - 1; + var sliceLen = sliceEnd - sliceStart + 1; - string q = string.Join(",", Enumerable.Repeat("?", sliceLen)); + var q = string.Join(",", Enumerable.Repeat("?", sliceLen)); //First we remove unwanted entries deleted += cmd.ExecuteNonQuery(@"DELETE FROM ""Fileset"" WHERE ""Timestamp"" IN (" + q + @") ", toDelete.Skip(sliceStart).Take(sliceLen).Select(Library.Utility.Utility.NormalizeDateTimeToEpochSeconds).Cast().ToArray()); } if (deleted != toDelete.Length) - throw new Exception(string.Format("Unexpected number of deleted filesets {0} vs {1}", deleted, toDelete.Length)); + throw new Exception($"Unexpected number of deleted filesets {deleted} vs {toDelete.Length}"); //Then we delete anything that is no longer being referenced cmd.ExecuteNonQuery(@"DELETE FROM ""FilesetEntry"" WHERE ""FilesetID"" NOT IN (SELECT DISTINCT ""ID"" FROM ""Fileset"")"); @@ -103,19 +99,15 @@ namespace Duplicati.Library.Main.Database cmd.ExecuteNonQuery(@"INSERT INTO ""DeletedBlock"" (""Hash"", ""Size"", ""VolumeID"") SELECT ""Hash"", ""Size"", ""VolumeID"" FROM ""Block"" WHERE ""ID"" NOT IN (SELECT DISTINCT ""BlockID"" AS ""BlockID"" FROM ""BlocksetEntry"" UNION SELECT DISTINCT ""ID"" FROM ""Block"", ""BlocklistHash"" WHERE ""Block"".""Hash"" = ""BlocklistHash"".""Hash"") "); cmd.ExecuteNonQuery(@"DELETE FROM ""Block"" WHERE ""ID"" NOT IN (SELECT DISTINCT ""BlockID"" FROM ""BlocksetEntry"" UNION SELECT DISTINCT ""ID"" FROM ""Block"", ""BlocklistHash"" WHERE ""Block"".""Hash"" = ""BlocklistHash"".""Hash"") "); - //Find all remote filesets that are no longer required, and mark them as delete - var updated = cmd.ExecuteNonQuery(@"UPDATE ""RemoteVolume"" SET ""State"" = ? WHERE ""Type"" = ? AND ""State"" IN (?, ?, ?) AND ""ID"" NOT IN (SELECT ""VolumeID"" FROM ""Fileset"") ", RemoteVolumeState.Deleting.ToString(), RemoteVolumeType.Files.ToString(), RemoteVolumeState.Uploaded.ToString(), RemoteVolumeState.Verified.ToString(), RemoteVolumeState.Temporary.ToString()); + //Find all remote filesets that are no longer required, and mark them as deleting + var updated = cmd.ExecuteNonQuery(@"UPDATE ""RemoteVolume"" SET ""State"" = ? WHERE ""Type"" = ? AND ""State"" IN (?, ?, ?, ?) AND ""ID"" NOT IN (SELECT ""VolumeID"" FROM ""Fileset"") ", RemoteVolumeState.Deleting.ToString(), RemoteVolumeType.Files.ToString(), RemoteVolumeState.Uploaded.ToString(), RemoteVolumeState.Verified.ToString(), RemoteVolumeState.Temporary.ToString(), RemoteVolumeState.Deleting.ToString()); if (deleted != updated) - throw new Exception(string.Format("Unexpected number of remote volumes marked as deleted. Found {0} filesets, but {1} volumes", deleted, updated)); + throw new Exception($"Unexpected number of remote volumes marked as deleted. Found {deleted} filesets, but {updated} volumes"); using (var rd = cmd.ExecuteReader(@"SELECT ""Name"", ""Size"" FROM ""RemoteVolume"" WHERE ""Type"" = ? AND ""State"" = ? ", RemoteVolumeType.Files.ToString(), RemoteVolumeState.Deleting.ToString())) - { while (rd.Read()) - { yield return new KeyValuePair(rd.GetString(0), rd.ConvertValueToInt64(1)); - } - } } } @@ -128,8 +120,8 @@ namespace Duplicati.Library.Main.Database { get { - List filesets = new List(); - using (IDbCommand cmd = this.m_connection.CreateCommand()) + var filesets = new List(); + using (var cmd = m_connection.CreateCommand()) { // We can also use the ROW_NUMBER() window function to generate the backup versions, // but this requires at least SQLite 3.25, which is not available in some common @@ -157,10 +149,10 @@ namespace Duplicati.Library.Main.Database public VolumeUsage(string name, long datasize, long wastedsize, long compressedsize) { - this.Name = name; - this.DataSize = datasize; - this.WastedSize = wastedsize; - this.CompressedSize = compressedsize; + Name = name; + DataSize = datasize; + WastedSize = wastedsize; + CompressedSize = compressedsize; } } @@ -170,7 +162,7 @@ namespace Duplicati.Library.Main.Database /// The sizes are the uncompressed values. /// /// A list of tuples with name, datasize, wastedbytes. - private IEnumerable GetWastedSpaceReport(System.Data.IDbTransaction transaction) + private IEnumerable GetWastedSpaceReport(IDbTransaction transaction) { var tmptablename = "UsageReport-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); @@ -185,21 +177,20 @@ namespace Duplicati.Library.Main.Database var combined = active + " UNION " + inactive + " UNION " + empty; var collected = @"SELECT ""VolumeID"" AS ""VolumeID"", SUM(""ActiveSize"") AS ""ActiveSize"", SUM(""InactiveSize"") AS ""InactiveSize"", MAX(""Sorttime"") AS ""Sorttime"" FROM (" + combined + @") GROUP BY ""VolumeID"" "; - var createtable = @$"CREATE {TEMPORARY} TABLE ""{tmptablename}"" AS " + collected; + var createtable = FormatInvariant($"{@$"CREATE {TEMPORARY} TABLE ""{tmptablename}"" AS "}{collected}"); - using (var cmd = m_connection.CreateCommand()) + using (var cmd = m_connection.CreateCommand(transaction)) { - cmd.Transaction = transaction; try { cmd.ExecuteNonQuery(createtable, RemoteVolumeType.Blocks.ToString(), RemoteVolumeState.Uploaded.ToString(), RemoteVolumeState.Verified.ToString()); - using (var rd = cmd.ExecuteReader(string.Format(@"SELECT ""A"".""Name"", ""B"".""ActiveSize"", ""B"".""InactiveSize"", ""A"".""Size"" FROM ""Remotevolume"" A, ""{0}"" B WHERE ""A"".""ID"" = ""B"".""VolumeID"" ORDER BY ""B"".""Sorttime"" ASC ", tmptablename))) + using (var rd = cmd.ExecuteReader(FormatInvariant($@"SELECT ""A"".""Name"", ""B"".""ActiveSize"", ""B"".""InactiveSize"", ""A"".""Size"" FROM ""Remotevolume"" A, ""{tmptablename}"" B WHERE ""A"".""ID"" = ""B"".""VolumeID"" ORDER BY ""B"".""Sorttime"" ASC "))) while (rd.Read()) yield return new VolumeUsage(rd.GetValue(0).ToString(), rd.ConvertValueToInt64(1, 0) + rd.ConvertValueToInt64(2, 0), rd.ConvertValueToInt64(2, 0), rd.ConvertValueToInt64(3, 0)); } finally { - try { cmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"" ", tmptablename)); } + try { cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{tmptablename}"" ")); } catch { } } } @@ -303,7 +294,7 @@ namespace Duplicati.Library.Main.Database } } - public ICompactReport GetCompactReport(long volsize, long wastethreshold, long smallfilesize, long maxsmallfilecount, System.Data.IDbTransaction transaction) + public ICompactReport GetCompactReport(long volsize, long wastethreshold, long smallfilesize, long maxsmallfilecount, IDbTransaction transaction) { return new CompactReport(volsize, wastethreshold, smallfilesize, maxsmallfilecount, GetWastedSpaceReport(transaction).ToList()); } @@ -311,24 +302,19 @@ namespace Duplicati.Library.Main.Database public interface IBlockQuery : IDisposable { - bool UseBlock(string hash, long size, System.Data.IDbTransaction transaction); + bool UseBlock(string hash, long size, IDbTransaction transaction); } private class BlockQuery : IBlockQuery { - private System.Data.IDbCommand m_command; + private IDbCommand m_command; - public BlockQuery(System.Data.IDbConnection con, System.Data.IDbTransaction transaction) + public BlockQuery(IDbConnection con, IDbTransaction transaction) { - m_command = con.CreateCommand(); - m_command.Transaction = transaction; - - m_command.Parameters.Clear(); - m_command.CommandText = @"SELECT ""VolumeID"" FROM ""Block"" WHERE ""Hash"" = ? AND ""Size"" = ? "; - m_command.AddParameters(2); + m_command = con.CreateCommand(transaction, @"SELECT ""VolumeID"" FROM ""Block"" WHERE ""Hash"" = ? AND ""Size"" = ? "); } - public bool UseBlock(string hash, long size, System.Data.IDbTransaction transaction) + public bool UseBlock(string hash, long size, IDbTransaction transaction) { m_command.Transaction = transaction; m_command.SetParameterValue(0, hash); @@ -348,7 +334,7 @@ namespace Duplicati.Library.Main.Database /// /// Builds a lookup table to enable faster response to block queries /// - public IBlockQuery CreateBlockQueryHelper(System.Data.IDbTransaction transaction) + public IBlockQuery CreateBlockQueryHelper(IDbTransaction transaction) { return new BlockQuery(m_connection, transaction); } @@ -360,7 +346,7 @@ namespace Duplicati.Library.Main.Database /// The size of the block /// The new volume ID /// The transaction to execute the command in - public void RegisterDuplicatedBlock(string hash, long size, long volumeID, System.Data.IDbTransaction tr) + public void RegisterDuplicatedBlock(string hash, long size, long volumeID, IDbTransaction tr) { m_registerDuplicateBlockCommand.Transaction = tr; m_registerDuplicateBlockCommand.SetParameterValue(0, volumeID); @@ -376,7 +362,7 @@ namespace Duplicati.Library.Main.Database /// The file to remove /// The volume IDs that will be removed /// The transaction to execute the command in - public void PrepareForDelete(string filename, IEnumerable volumeIdsToBeRemoved, System.Data.IDbTransaction transaction) + public void PrepareForDelete(string filename, IEnumerable volumeIdsToBeRemoved, IDbTransaction transaction) { var deletedVolume = GetRemoteVolume(filename, transaction); if (deletedVolume.Type != RemoteVolumeType.Blocks) @@ -388,26 +374,26 @@ namespace Duplicati.Library.Main.Database var replacementBlocks = "ReplacementBlocks-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); try { - cmd.ExecuteNonQuery(@$"CREATE {TEMPORARY} TABLE """ + updatedBlocks + @""" AS SELECT ""ID"" FROM ""Block"" WHERE ""VolumeID"" = ? ", deletedVolume.ID); - cmd.ExecuteNonQuery(@$"CREATE {TEMPORARY} TABLE """ + replacementBlocks + @""" AS SELECT ""BlockID"", MAX(""VolumeID"") AS ""VolumeID"" FROM ""DuplicateBlock"" WHERE ""VolumeID"" NOT IN (?) AND ""BlockID"" IN (SELECT ""ID"" FROM """ + updatedBlocks + @""") GROUP BY ""BlockID"" ", volumeIdsToBeRemoved.ToArray()); - var targetCount = cmd.ExecuteScalarInt64(@"SELECT COUNT(*) FROM """ + updatedBlocks + @""" "); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE {TEMPORARY} TABLE ""{updatedBlocks}"" AS SELECT ""ID"" FROM ""Block"" WHERE ""VolumeID"" = ? "), deletedVolume.ID); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE {TEMPORARY} TABLE ""{replacementBlocks}"" AS SELECT ""BlockID"", MAX(""VolumeID"") AS ""VolumeID"" FROM ""DuplicateBlock"" WHERE ""VolumeID"" NOT IN (?) AND ""BlockID"" IN (SELECT ""ID"" FROM ""{updatedBlocks}"") GROUP BY ""BlockID"" "), volumeIdsToBeRemoved.ToArray()); + var targetCount = cmd.ExecuteScalarInt64(FormatInvariant($@"SELECT COUNT(*) FROM ""{updatedBlocks}"" ")); if (targetCount == 0) return; var replacementCount = cmd.ExecuteScalarInt64(@"SELECT COUNT(*) FROM """ + replacementBlocks + @""" "); - var updateCount = cmd.ExecuteNonQuery(@$"UPDATE ""Block"" SET ""VolumeID"" = (SELECT ""VolumeID"" FROM ""{replacementBlocks}"" WHERE ""{replacementBlocks}"".""BlockID"" = ""Block"".""ID"" AND ""Block"".""VolumeID"" = ?) WHERE ""Block"".""VolumeID"" = ? ", deletedVolume.ID, deletedVolume.ID); - var deleteCount = cmd.ExecuteNonQuery(@$"DELETE FROM ""DuplicateBlock"" WHERE (""DuplicateBlock"".""BlockID"" || ':' || ""DuplicateBlock"".""VolumeID"") IN (SELECT ""RB"".""BlockID"" || ':' || ""RB"".""VolumeID"" FROM ""{replacementBlocks}"" RB)"); + var updateCount = cmd.ExecuteNonQuery(FormatInvariant(@$"UPDATE ""Block"" SET ""VolumeID"" = (SELECT ""VolumeID"" FROM ""{replacementBlocks}"" WHERE ""{replacementBlocks}"".""BlockID"" = ""Block"".""ID"" AND ""Block"".""VolumeID"" = ?) WHERE ""Block"".""VolumeID"" = ? "), deletedVolume.ID, deletedVolume.ID); + var deleteCount = cmd.ExecuteNonQuery(FormatInvariant(@$"DELETE FROM ""DuplicateBlock"" WHERE (""DuplicateBlock"".""BlockID"" || ':' || ""DuplicateBlock"".""VolumeID"") IN (SELECT ""RB"".""BlockID"" || ':' || ""RB"".""VolumeID"" FROM ""{replacementBlocks}"" RB)")); if (targetCount != updateCount || replacementCount != deleteCount || updateCount != deleteCount) - throw new Exception(string.Format($"Unexpected number of rows updated. Expected {targetCount} but got updated {updateCount}, deleted {deleteCount}, and replaced {replacementCount}")); + throw new Exception($"Unexpected number of rows updated. Expected {targetCount} but got updated {updateCount}, deleted {deleteCount}, and replaced {replacementCount}"); // Remove knowledge of any old blocks - cmd.ExecuteNonQuery(@"DELETE FROM ""DuplicateBlock"" WHERE ""VolumeID"" = ?", deletedVolume.ID); + cmd.ExecuteNonQuery(FormatInvariant(@$"DELETE FROM ""DuplicateBlock"" WHERE ""VolumeID"" = ?"), deletedVolume.ID); } finally { - try { cmd.ExecuteNonQuery(@"DROP TABLE IF EXISTS """ + updatedBlocks + @""" "); } + try { cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{updatedBlocks}"" ")); } catch { } - try { cmd.ExecuteNonQuery(@"DROP TABLE IF EXISTS """ + replacementBlocks + @""" "); } + try { cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{replacementBlocks}"" ")); } catch { } } @@ -420,7 +406,7 @@ namespace Duplicati.Library.Main.Database /// Block volumes slated for deletion. /// The transaction to execute the command in /// The deletable volumes. - public IEnumerable ReOrderDeleteableVolumes(IEnumerable deleteableVolumes, System.Data.IDbTransaction transaction) + public IEnumerable ReOrderDeleteableVolumes(IEnumerable deleteableVolumes, IDbTransaction transaction) { using (var cmd = m_connection.CreateCommand(transaction)) { diff --git a/Duplicati/Library/Main/Database/LocalListAffectedDatabase.cs b/Duplicati/Library/Main/Database/LocalListAffectedDatabase.cs index a51dba614..7a3dd5448 100644 --- a/Duplicati/Library/Main/Database/LocalListAffectedDatabase.cs +++ b/Duplicati/Library/Main/Database/LocalListAffectedDatabase.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -33,7 +33,7 @@ namespace Duplicati.Library.Main.Database ShouldCloseConnection = true; } - private class ListResultFileset : Duplicati.Library.Interface.IListResultFileset + private class ListResultFileset : Interface.IListResultFileset { public long Version { get; set; } public int IsFullBackup { get; set; } @@ -42,120 +42,111 @@ namespace Duplicati.Library.Main.Database public long FileSizes { get; set; } } - private class ListResultFile : Duplicati.Library.Interface.IListResultFile + private class ListResultFile : Interface.IListResultFile { public string Path { get; set; } public IEnumerable Sizes { get; set; } } - private class ListResultRemoteLog : Duplicati.Library.Interface.IListResultRemoteLog + private class ListResultRemoteLog : Interface.IListResultRemoteLog { public DateTime Timestamp { get; set; } public string Message { get; set; } } - private class ListResultRemoteVolume : Duplicati.Library.Interface.IListResultRemoteVolume + private class ListResultRemoteVolume : Interface.IListResultRemoteVolume { public string Name { get; set; } } - public IEnumerable GetFilesets(IEnumerable items) + public IEnumerable GetFilesets(IEnumerable items) { var filesets = FilesetTimes.ToArray(); var dict = new Dictionary(); - for(var i = 0; i < filesets.Length; i++) + for (var i = 0; i < filesets.Length; i++) dict[filesets[i].Key] = i; - var sql = string.Format( - @"SELECT DISTINCT ""FilesetID"" FROM (" + - @"SELECT ""FilesetID"" FROM ""FilesetEntry"" WHERE ""FileID"" IN ( SELECT ""ID"" FROM ""FileLookup"" WHERE ""BlocksetID"" IN ( SELECT ""BlocksetID"" FROM ""BlocksetEntry"" WHERE ""BlockID"" IN ( SELECT ""ID"" From ""Block"" WHERE ""VolumeID"" IN ( SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Name"" IN ({0})))))" + - " UNION " + - @"SELECT ""ID"" FROM ""Fileset"" WHERE ""VolumeID"" IN ( SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Name"" IN ({0}))" + - ")", - string.Join(",", items.Select(x => "?")) - ); + var sql = FormatInvariant($@"SELECT DISTINCT ""FilesetID"" FROM ( +SELECT ""FilesetID"" FROM ""FilesetEntry"" WHERE ""FileID"" IN ( SELECT ""ID"" FROM ""FileLookup"" WHERE ""BlocksetID"" IN ( SELECT ""BlocksetID"" FROM ""BlocksetEntry"" WHERE ""BlockID"" IN ( SELECT ""ID"" From ""Block"" WHERE ""VolumeID"" IN ( SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Name"" IN ({string.Join(",", items.Select(x => "?"))}))))) +UNION +SELECT ""ID"" FROM ""Fileset"" WHERE ""VolumeID"" IN ( SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Name"" IN ({string.Join(",", items.Select(x => "?"))})) +)"); var it = new List(items); it.AddRange(items); - using(var cmd = m_connection.CreateCommand()) - using(var rd = cmd.ExecuteReader(sql, it.ToArray())) + using (var cmd = m_connection.CreateCommand()) + using (var rd = cmd.ExecuteReader(sql, it.ToArray())) while (rd.Read()) { var v = dict[rd.GetInt64(0)]; - yield return new ListResultFileset() { + yield return new ListResultFileset() + { Version = v, Time = filesets[v].Value }; } } - public IEnumerable GetFiles(IEnumerable items) + public IEnumerable GetFiles(IEnumerable items) { - var sql = string.Format( - @"SELECT DISTINCT ""Path"" FROM (" + - @"SELECT ""Path"" FROM ""File"" WHERE ""BlocksetID"" IN (SELECT ""BlocksetID"" FROM ""BlocksetEntry"" WHERE ""BlockID"" IN (SELECT ""ID"" FROM ""Block"" WHERE ""VolumeID"" IN (SELECT ""ID"" from ""RemoteVolume"" WHERE ""Name"" IN ({0}))))" + - @" UNION " + - @"SELECT ""Path"" FROM ""File"" WHERE ""MetadataID"" IN (SELECT ""ID"" FROM ""Metadataset"" WHERE ""BlocksetID"" IN (SELECT ""BlocksetID"" FROM ""BlocksetEntry"" WHERE ""BlockID"" IN (SELECT ""ID"" FROM ""Block"" WHERE ""VolumeID"" IN (SELECT ""ID"" from ""RemoteVolume"" WHERE ""Name"" IN ({0})))))" + - @" UNION " + - @"SELECT ""Path"" FROM ""File"" WHERE ""ID"" IN ( SELECT ""FileID"" FROM ""FilesetEntry"" WHERE ""FilesetID"" IN ( SELECT ""ID"" FROM ""Fileset"" WHERE ""VolumeID"" IN ( SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Name"" IN ({0}))))" + - @") ORDER BY ""Path"" ", - string.Join(",", items.Select(x => "?")) - ); - + var sql = FormatInvariant($@"SELECT DISTINCT ""Path"" FROM ( +SELECT ""Path"" FROM ""File"" WHERE ""BlocksetID"" IN (SELECT ""BlocksetID"" FROM ""BlocksetEntry"" WHERE ""BlockID"" IN (SELECT ""ID"" FROM ""Block"" WHERE ""VolumeID"" IN (SELECT ""ID"" from ""RemoteVolume"" WHERE ""Name"" IN ({string.Join(",", items.Select(x => "?"))})))) + UNION +SELECT ""Path"" FROM ""File"" WHERE ""MetadataID"" IN (SELECT ""ID"" FROM ""Metadataset"" WHERE ""BlocksetID"" IN (SELECT ""BlocksetID"" FROM ""BlocksetEntry"" WHERE ""BlockID"" IN (SELECT ""ID"" FROM ""Block"" WHERE ""VolumeID"" IN (SELECT ""ID"" from ""RemoteVolume"" WHERE ""Name"" IN ({string.Join(",", items.Select(x => "?"))}))))) + UNION +SELECT ""Path"" FROM ""File"" WHERE ""ID"" IN ( SELECT ""FileID"" FROM ""FilesetEntry"" WHERE ""FilesetID"" IN ( SELECT ""ID"" FROM ""Fileset"" WHERE ""VolumeID"" IN ( SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Name"" IN ({string.Join(",", items.Select(x => "?"))})))) +) ORDER BY ""Path"""); + var it = new List(items); it.AddRange(items); it.AddRange(items); - using(var cmd = m_connection.CreateCommand()) - using(var rd = cmd.ExecuteReader(sql, it.ToArray())) + using (var cmd = m_connection.CreateCommand()) + using (var rd = cmd.ExecuteReader(sql, it.ToArray())) while (rd.Read()) - yield return new ListResultFile() { + yield return new ListResultFile() + { Path = Convert.ToString(rd.GetValue(0)) }; - + } - public IEnumerable GetLogLines(IEnumerable items) + public IEnumerable GetLogLines(IEnumerable items) { - var sql = string.Format( - @"SELECT ""TimeStamp"", ""Message"" || ' ' || CASE WHEN ""Exception"" IS NULL THEN '' ELSE ""Exception"" END FROM ""LogData"" WHERE {0}" + - @" UNION " + - @"SELECT ""Timestamp"", ""Data"" FROM ""RemoteOperation"" WHERE ""Path"" IN ({1})", - string.Join(" OR ", items.Select(x => @"""Message"" LIKE ?")), - string.Join(",", items.Select(x => "?")) - ); + var sql = FormatInvariant($@" +SELECT ""TimeStamp"", ""Message"" || ' ' || CASE WHEN ""Exception"" IS NULL THEN '' ELSE ""Exception"" END FROM ""LogData"" WHERE {string.Join(" OR ", items.Select(x => @"""Message"" LIKE ?"))} +UNION +SELECT ""Timestamp"", ""Data"" FROM ""RemoteOperation"" WHERE ""Path"" IN ({string.Join(",", items.Select(x => "?"))})"); var it = new List(from n in items select "%" + n + "%"); it.AddRange(items); - using(var cmd = m_connection.CreateCommand()) - using(var rd = cmd.ExecuteReader(sql, it.ToArray())) + using (var cmd = m_connection.CreateCommand()) + using (var rd = cmd.ExecuteReader(sql, it.ToArray())) while (rd.Read()) - yield return new ListResultRemoteLog() { + yield return new ListResultRemoteLog() + { Timestamp = ParseFromEpochSeconds(rd.GetInt64(0)), Message = rd.GetString(1) }; } - public IEnumerable GetVolumes(IEnumerable items) + public IEnumerable GetVolumes(IEnumerable items) { - var sql = string.Format( - @"SELECT DISTINCT ""Name"" FROM ( " + - @" SELECT ""Name"" FROM ""Remotevolume"" WHERE ""ID"" IN ( SELECT ""VolumeID"" FROM ""Block"" WHERE ""ID"" IN ( SELECT ""BlockID"" FROM ""BlocksetEntry"" WHERE ""BlocksetID"" IN ( SELECT ""BlocksetID"" FROM ""FileLookup"" WHERE ""ID"" IN ( SELECT ""FileID"" FROM ""FilesetEntry"" WHERE ""FilesetID"" IN ( SELECT ""ID"" FROM ""Fileset"" WHERE ""VolumeID"" IN ( SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Name"" IN ({0}))))))) " + - @" UNION " + - @" SELECT ""Name"" FROM ""Remotevolume"" WHERE ""ID"" IN ( SELECT ""VolumeID"" FROM ""Block"" WHERE ""ID"" IN ( SELECT ""BlockID"" FROM ""BlocksetEntry"" WHERE ""BlocksetID"" IN ( SELECT ""BlocksetID"" FROM ""Metadataset"" WHERE ""ID"" IN ( SELECT ""MetadataID"" FROM ""FileLookup"" WHERE ""ID"" IN ( SELECT ""FileID"" FROM ""FilesetEntry"" WHERE ""FilesetID"" IN ( SELECT ""ID"" FROM ""Fileset"" WHERE ""VolumeID"" IN ( SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Name"" IN ({0}))))))))" + - @")", - string.Join(",", items.Select(x => "?")) - ); - + var sql = FormatInvariant($@"SELECT DISTINCT ""Name"" FROM ( +SELECT ""Name"" FROM ""Remotevolume"" WHERE ""ID"" IN ( SELECT ""VolumeID"" FROM ""Block"" WHERE ""ID"" IN ( SELECT ""BlockID"" FROM ""BlocksetEntry"" WHERE ""BlocksetID"" IN ( SELECT ""BlocksetID"" FROM ""FileLookup"" WHERE ""ID"" IN ( SELECT ""FileID"" FROM ""FilesetEntry"" WHERE ""FilesetID"" IN ( SELECT ""ID"" FROM ""Fileset"" WHERE ""VolumeID"" IN ( SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Name"" IN ({string.Join(",", items.Select(x => "?"))}))))))) +UNION +SELECT ""Name"" FROM ""Remotevolume"" WHERE ""ID"" IN ( SELECT ""VolumeID"" FROM ""Block"" WHERE ""ID"" IN ( SELECT ""BlockID"" FROM ""BlocksetEntry"" WHERE ""BlocksetID"" IN ( SELECT ""BlocksetID"" FROM ""Metadataset"" WHERE ""ID"" IN ( SELECT ""MetadataID"" FROM ""FileLookup"" WHERE ""ID"" IN ( SELECT ""FileID"" FROM ""FilesetEntry"" WHERE ""FilesetID"" IN ( SELECT ""ID"" FROM ""Fileset"" WHERE ""VolumeID"" IN ( SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Name"" IN ({string.Join(",", items.Select(x => "?"))})))))))) +)"); var it = new List(items); it.AddRange(items); - using(var cmd = m_connection.CreateCommand()) - using(var rd = cmd.ExecuteReader(sql, it.ToArray())) + using (var cmd = m_connection.CreateCommand()) + using (var rd = cmd.ExecuteReader(sql, it.ToArray())) while (rd.Read()) - yield return new ListResultRemoteVolume() { + yield return new ListResultRemoteVolume() + { Name = Convert.ToString(rd.GetValue(0)) }; } diff --git a/Duplicati/Library/Main/Database/LocalListBrokenFilesDatabase.cs b/Duplicati/Library/Main/Database/LocalListBrokenFilesDatabase.cs index cf6e07aad..e85232092 100644 --- a/Duplicati/Library/Main/Database/LocalListBrokenFilesDatabase.cs +++ b/Duplicati/Library/Main/Database/LocalListBrokenFilesDatabase.cs @@ -1,33 +1,34 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; +using System.Data; using System.Linq; namespace Duplicati.Library.Main.Database { internal class LocalListBrokenFilesDatabase : LocalDatabase { - private const string BLOCK_VOLUME_IDS = @"SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Type"" = '{2}'"; + private static readonly string BLOCK_VOLUME_IDS = FormatInvariant($@"SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Type"" = '{RemoteVolumeType.Blocks.ToString()}'"); // Invalid blocksets include those that: // - Have BlocksetEntries with unknown/invalid blocks (meaning the data to rebuild the blockset isn't available) @@ -35,9 +36,9 @@ namespace Duplicati.Library.Main.Database // - Have BlocklistHash entries with unknown/invalid blocks (meaning the data which defines the list of hashes that makes up the blockset isn't available) // - Are defined in the Blockset table but have no entries in the BlocksetEntries table (this can happen during recreate if Files volumes reference blocksets that are not found in any Index files) // However, blocksets with a length of 0 are excluded from this check, as the corresponding blocks for these are not needed. - private const string BROKEN_FILE_IDS = @" + private static readonly string BROKEN_FILE_IDS = FormatInvariant($@" SELECT DISTINCT ""ID"" FROM ( - SELECT ""ID"" AS ""ID"", ""BlocksetID"" AS ""BlocksetID"" FROM ""FileLookup"" WHERE ""BlocksetID"" != {0} AND ""BlocksetID"" != {1} + SELECT ""ID"" AS ""ID"", ""BlocksetID"" AS ""BlocksetID"" FROM ""FileLookup"" WHERE ""BlocksetID"" != {FOLDER_BLOCKSET_ID} AND ""BlocksetID"" != {SYMLINK_BLOCKSET_ID} UNION SELECT ""A"".""ID"" AS ""ID"", ""B"".""BlocksetID"" AS ""BlocksetID"" FROM ""FileLookup"" A LEFT JOIN ""Metadataset"" B ON ""A"".""MetadataID"" = ""B"".""ID"" ) @@ -47,26 +48,22 @@ WHERE ""BlocksetID"" IS NULL OR ""BlocksetID"" IN ( SELECT ""BlocksetID"" FROM ""BlocksetEntry"" WHERE ""BlockID"" NOT IN (SELECT ""ID"" FROM ""Block"" WHERE ""VolumeID"" IN - (" + BLOCK_VOLUME_IDS + @")) + ({BLOCK_VOLUME_IDS})) UNION SELECT ""BlocksetID"" FROM ""BlocklistHash"" WHERE ""Hash"" NOT IN (SELECT ""Hash"" FROM ""Block"" WHERE ""VolumeID"" IN - (" + BLOCK_VOLUME_IDS + @")) + ({BLOCK_VOLUME_IDS})) UNION SELECT ""A"".""ID"" AS ""BlocksetID"" FROM ""Blockset"" A LEFT JOIN ""BlocksetEntry"" B ON ""A"".""ID"" = ""B"".""BlocksetID"" WHERE ""A"".""Length"" > 0 AND ""B"".""BlocksetID"" IS NULL ) WHERE ""BlocksetID"" NOT IN (SELECT ""ID"" FROM ""Blockset"" WHERE ""Length"" == 0) ) -"; - private const string BROKEN_FILE_SETS = @"SELECT DISTINCT ""B"".""Timestamp"", ""A"".""FilesetID"", COUNT(""A"".""FileID"") AS ""FileCount"" FROM ""FilesetEntry"" A, ""Fileset"" B WHERE ""A"".""FilesetID"" = ""B"".""ID"" AND ""A"".""FileID"" IN (" + BROKEN_FILE_IDS + @")"; +"); + private static readonly string BROKEN_FILE_SETS = FormatInvariant($@"SELECT DISTINCT ""B"".""Timestamp"", ""A"".""FilesetID"", COUNT(""A"".""FileID"") AS ""FileCount"" FROM ""FilesetEntry"" A, ""Fileset"" B WHERE ""A"".""FilesetID"" = ""B"".""ID"" AND ""A"".""FileID"" IN ({BROKEN_FILE_IDS})"); - private const string BROKEN_FILE_NAMES = @" -SELECT ""A"".""Path"", ""B"".""Length"" FROM ""File"" A, ""Blockset"" B WHERE ""A"".""BlocksetID"" = ""B"".""ID"" AND ""A"".""ID"" IN (" -+ BROKEN_FILE_IDS + -@") AND ""A"".""ID"" IN (SELECT ""FileID"" FROM ""FilesetEntry"" WHERE ""FilesetID"" = ?)"; + private static readonly string BROKEN_FILE_NAMES = FormatInvariant($@"SELECT ""A"".""Path"", ""B"".""Length"" FROM ""File"" A, ""Blockset"" B WHERE ""A"".""BlocksetID"" = ""B"".""ID"" AND ""A"".""ID"" IN ({BROKEN_FILE_IDS}) AND ""A"".""ID"" IN (SELECT ""FileID"" FROM ""FilesetEntry"" WHERE ""FilesetID"" = ?)"); - private const string INSERT_BROKEN_IDS = @"INSERT INTO ""{3}"" (""{4}"") " + BROKEN_FILE_IDS - + @" AND ""ID"" IN (SELECT ""FileID"" FROM ""FilesetEntry"" WHERE ""FilesetID"" = ?)"; + private static string INSERT_BROKEN_IDS(string tablename, string IDfieldname) => FormatInvariant($@"INSERT INTO ""{tablename}"" (""{IDfieldname}"") {BROKEN_FILE_IDS} AND ""ID"" IN (SELECT ""FileID"" FROM ""FilesetEntry"" WHERE ""FilesetID"" = ?)"); public LocalListBrokenFilesDatabase(string path) : base(path, "ListBrokenFiles", false) @@ -80,12 +77,12 @@ SELECT ""A"".""Path"", ""B"".""Length"" FROM ""File"" A, ""Blockset"" B WHERE "" ShouldCloseConnection = false; } - public IEnumerable> GetBrokenFilesets(DateTime time, long[] versions, System.Data.IDbTransaction transaction) + public IEnumerable> GetBrokenFilesets(DateTime time, long[] versions, IDbTransaction transaction) { - var query = string.Format(BROKEN_FILE_SETS, FOLDER_BLOCKSET_ID, SYMLINK_BLOCKSET_ID, RemoteVolumeType.Blocks.ToString()); + var query = BROKEN_FILE_SETS; var clause = GetFilelistWhereClause(time, versions); if (!string.IsNullOrWhiteSpace(clause.Item1)) - query += @" AND ""A"".""FilesetID"" IN (SELECT ""ID"" FROM ""Fileset"" " + clause.Item1 + ")"; + query += FormatInvariant($@" AND ""A"".""FilesetID"" IN (SELECT ""ID"" FROM ""Fileset"" {clause.Item1})"); query += @" GROUP BY ""A"".""FilesetID"""; @@ -95,21 +92,21 @@ SELECT ""A"".""Path"", ""B"".""Length"" FROM ""File"" A, ""Blockset"" B WHERE "" yield return new Tuple(ParseFromEpochSeconds(rd.ConvertValueToInt64(0, 0)), rd.ConvertValueToInt64(1, -1), rd.ConvertValueToInt64(2, 0)); } - public IEnumerable> GetBrokenFilenames(long filesetid, System.Data.IDbTransaction transaction) + public IEnumerable> GetBrokenFilenames(long filesetid, IDbTransaction transaction) { using (var cmd = Connection.CreateCommand(transaction)) - foreach (var rd in cmd.ExecuteReaderEnumerable(string.Format(BROKEN_FILE_NAMES, FOLDER_BLOCKSET_ID, SYMLINK_BLOCKSET_ID, RemoteVolumeType.Blocks.ToString()), filesetid)) + foreach (var rd in cmd.ExecuteReaderEnumerable(BROKEN_FILE_NAMES, filesetid)) if (!rd.IsDBNull(0)) yield return new Tuple(rd.ConvertValueToString(0), rd.ConvertValueToInt64(1)); } - public void InsertBrokenFileIDsIntoTable(long filesetid, string tablename, string IDfieldname, System.Data.IDbTransaction transaction) + public void InsertBrokenFileIDsIntoTable(long filesetid, string tablename, string IDfieldname, IDbTransaction transaction) { using (var cmd = Connection.CreateCommand(transaction)) - cmd.ExecuteNonQuery(string.Format(INSERT_BROKEN_IDS, FOLDER_BLOCKSET_ID, SYMLINK_BLOCKSET_ID, RemoteVolumeType.Blocks.ToString(), tablename, IDfieldname), filesetid); + cmd.ExecuteNonQuery(INSERT_BROKEN_IDS(tablename, IDfieldname), filesetid); } - public void RemoveMissingBlocks(IEnumerable names, System.Data.IDbTransaction transaction) + public void RemoveMissingBlocks(IEnumerable names, IDbTransaction transaction) { if (names == null || !names.Any()) return; if (transaction == null) @@ -117,12 +114,12 @@ SELECT ""A"".""Path"", ""B"".""Length"" FROM ""File"" A, ""Blockset"" B WHERE "" using (var deletecmd = m_connection.CreateCommand(transaction)) { - string temptransguid = Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); + var temptransguid = Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); var volidstable = "DelVolSetIds-" + temptransguid; // Create and fill a temp table with the volids to delete. We avoid using too many parameters that way. - deletecmd.ExecuteNonQuery(string.Format(@"CREATE TEMP TABLE ""{0}"" (""ID"" INTEGER PRIMARY KEY)", volidstable)); - deletecmd.CommandText = string.Format(@"INSERT OR IGNORE INTO ""{0}"" (""ID"") VALUES (?)", volidstable); + deletecmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMP TABLE ""{volidstable}"" (""ID"" INTEGER PRIMARY KEY)")); + deletecmd.CommandText = FormatInvariant($@"INSERT OR IGNORE INTO ""{volidstable}"" (""ID"") VALUES (?)"); deletecmd.Parameters.Clear(); deletecmd.AddParameters(1); foreach (var name in names) @@ -131,28 +128,28 @@ SELECT ""A"".""Path"", ""B"".""Length"" FROM ""File"" A, ""Blockset"" B WHERE "" deletecmd.SetParameterValue(0, volumeid); deletecmd.ExecuteNonQuery(); } - var volIdsSubQuery = string.Format(@"SELECT ""ID"" FROM ""{0}"" ", volidstable); + var volIdsSubQuery = FormatInvariant($@"SELECT ""ID"" FROM ""{volidstable}"" "); deletecmd.Parameters.Clear(); - deletecmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""IndexBlockLink"" WHERE ""BlockVolumeID"" IN ({0}) OR ""IndexVolumeID"" IN ({0})", volIdsSubQuery)); - deletecmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""Block"" WHERE ""VolumeID"" IN ({0})", volIdsSubQuery)); - deletecmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""DeletedBlock"" WHERE ""VolumeID"" IN ({0})", volIdsSubQuery)); - deletecmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""DuplicateBlock"" WHERE ""VolumeID"" IN ({0})", volIdsSubQuery)); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""IndexBlockLink"" WHERE ""BlockVolumeID"" IN ({volIdsSubQuery}) OR ""IndexVolumeID"" IN ({volIdsSubQuery})")); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""Block"" WHERE ""VolumeID"" IN ({volIdsSubQuery})")); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""DeletedBlock"" WHERE ""VolumeID"" IN ({volIdsSubQuery})")); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""DuplicateBlock"" WHERE ""VolumeID"" IN ({volIdsSubQuery})")); // Clean up temp tables for subqueries. We truncate content and then try to delete. // Drop in try-block, as it fails in nested transactions (SQLite problem) - // System.Data.SQLite.SQLiteException (0x80004005): database table is locked - deletecmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""{0}"" ", volidstable)); + // SQLite.SQLiteException (0x80004005): database table is locked + deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""{volidstable}"" ")); try { deletecmd.CommandTimeout = 2; - deletecmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"" ", volidstable)); + deletecmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{volidstable}"" ")); } catch { /* Ignore, will be deleted on close anyway. */ } } } - public long GetFilesetFileCount(long filesetid, System.Data.IDbTransaction transaction) + public long GetFilesetFileCount(long filesetid, IDbTransaction transaction) { using (var cmd = m_connection.CreateCommand(transaction)) return cmd.ExecuteScalarInt64(@"SELECT COUNT(*) FROM ""FilesetEntry"" WHERE ""FilesetID"" = ?", 0, filesetid); diff --git a/Duplicati/Library/Main/Database/LocalListChangesDatabase.cs b/Duplicati/Library/Main/Database/LocalListChangesDatabase.cs index bd84a6073..fda6f663d 100644 --- a/Duplicati/Library/Main/Database/LocalListChangesDatabase.cs +++ b/Duplicati/Library/Main/Database/LocalListChangesDatabase.cs @@ -1,25 +1,26 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; +using System.Data; using System.Collections.Generic; using Duplicati.Library.Utility; @@ -32,16 +33,16 @@ namespace Duplicati.Library.Main.Database { ShouldCloseConnection = true; } - + public interface IStorageHelper : IDisposable { - void AddElement(string path, string filehash, string metahash, long size, Library.Interface.ListChangesElementType type, bool asNew); - - void AddFromDb(long filesetId, bool asNew, Library.Utility.IFilter filter); - + void AddElement(string path, string filehash, string metahash, long size, Interface.ListChangesElementType type, bool asNew); + + void AddFromDb(long filesetId, bool asNew, IFilter filter); + IChangeCountReport CreateChangeCountReport(); IChangeSizeReport CreateChangeSizeReport(); - IEnumerable> CreateChangedFileReport(); + IEnumerable> CreateChangedFileReport(); } public interface IChangeCountReport @@ -49,11 +50,11 @@ namespace Duplicati.Library.Main.Database long AddedFolders { get; } long AddedSymlinks { get; } long AddedFiles { get; } - + long DeletedFolders { get; } long DeletedSymlinks { get; } long DeletedFiles { get; } - + long ModifiedFolders { get; } long ModifiedSymlinks { get; } long ModifiedFiles { get; } @@ -63,21 +64,21 @@ namespace Duplicati.Library.Main.Database { long AddedSize { get; } long DeletedSize { get; } - + long PreviousSize { get; } long CurrentSize { get; } } - + internal class ChangeCountReport : IChangeCountReport { - public long AddedFolders { get; internal set; } - public long AddedSymlinks { get; internal set; } + public long AddedFolders { get; internal set; } + public long AddedSymlinks { get; internal set; } public long AddedFiles { get; internal set; } - + public long DeletedFolders { get; internal set; } - public long DeletedSymlinks { get; internal set; } - public long DeletedFiles { get; internal set; } - + public long DeletedSymlinks { get; internal set; } + public long DeletedFiles { get; internal set; } + public long ModifiedFolders { get; internal set; } public long ModifiedSymlinks { get; internal set; } public long ModifiedFiles { get; internal set; } @@ -87,66 +88,56 @@ namespace Duplicati.Library.Main.Database { public long AddedSize { get; internal set; } public long DeletedSize { get; internal set; } - + public long PreviousSize { get; internal set; } public long CurrentSize { get; internal set; } } - + private class StorageHelper : IStorageHelper { - private readonly System.Data.IDbConnection m_connection; - private System.Data.IDbTransaction m_transaction; - - private System.Data.IDbCommand m_insertPreviousElementCommand; - private System.Data.IDbCommand m_insertCurrentElementCommand; - + private readonly IDbConnection m_connection; + private IDbTransaction m_transaction; + + private IDbCommand m_insertPreviousElementCommand; + private IDbCommand m_insertCurrentElementCommand; + private string m_previousTable; private string m_currentTable; - - public StorageHelper(System.Data.IDbConnection con) + + public StorageHelper(IDbConnection con) { m_connection = con; m_previousTable = "Previous-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); m_currentTable = "Current-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); - + m_transaction = m_connection.BeginTransaction(); - - using(var cmd = m_connection.CreateCommand()) + + using (var cmd = m_connection.CreateCommand(m_transaction)) { - cmd.Transaction = m_transaction; - - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" (""Path"" TEXT NOT NULL, ""FileHash"" TEXT NULL, ""MetaHash"" TEXT NOT NULL, ""Size"" INTEGER NOT NULL, ""Type"" INTEGER NOT NULL) ", m_previousTable)); - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" (""Path"" TEXT NOT NULL, ""FileHash"" TEXT NULL, ""MetaHash"" TEXT NOT NULL, ""Size"" INTEGER NOT NULL, ""Type"" INTEGER NOT NULL) ", m_currentTable)); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{m_previousTable}"" (""Path"" TEXT NOT NULL, ""FileHash"" TEXT NULL, ""MetaHash"" TEXT NOT NULL, ""Size"" INTEGER NOT NULL, ""Type"" INTEGER NOT NULL) ")); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{m_currentTable}"" (""Path"" TEXT NOT NULL, ""FileHash"" TEXT NULL, ""MetaHash"" TEXT NOT NULL, ""Size"" INTEGER NOT NULL, ""Type"" INTEGER NOT NULL) ")); } - - m_insertPreviousElementCommand = m_connection.CreateCommand(); - m_insertPreviousElementCommand.Transaction = m_transaction; - m_insertPreviousElementCommand.CommandText = string.Format(@"INSERT INTO ""{0}"" (""Path"", ""FileHash"", ""MetaHash"", ""Size"", ""Type"") VALUES (?,?,?,?,?)", m_previousTable); - m_insertPreviousElementCommand.AddParameters(5); - - m_insertCurrentElementCommand = m_connection.CreateCommand(); - m_insertCurrentElementCommand.Transaction = m_transaction; - m_insertCurrentElementCommand.CommandText = string.Format(@"INSERT INTO ""{0}"" (""Path"", ""FileHash"", ""MetaHash"", ""Size"", ""Type"") VALUES (?,?,?,?,?)", m_currentTable); - m_insertCurrentElementCommand.AddParameters(5); + + m_insertPreviousElementCommand = m_connection.CreateCommand(m_transaction, FormatInvariant($@"INSERT INTO ""{m_previousTable}"" (""Path"", ""FileHash"", ""MetaHash"", ""Size"", ""Type"") VALUES (?,?,?,?,?)")); + m_insertCurrentElementCommand = m_connection.CreateCommand(FormatInvariant($@"INSERT INTO ""{m_currentTable}"" (""Path"", ""FileHash"", ""MetaHash"", ""Size"", ""Type"") VALUES (?,?,?,?,?)")); } - - public void AddFromDb(long filesetId, bool asNew, Library.Utility.IFilter filter) + + public void AddFromDb(long filesetId, bool asNew, IFilter filter) { var tablename = asNew ? m_currentTable : m_previousTable; - - var folders = string.Format(@"SELECT ""File"".""Path"" AS ""Path"", NULL AS ""FileHash"", ""Blockset"".""Fullhash"" AS ""MetaHash"", -1 AS ""Size"", {0} AS ""Type"", ""FilesetEntry"".""FilesetID"" AS ""FilesetID"" FROM ""File"",""FilesetEntry"",""Metadataset"",""Blockset"" WHERE ""File"".""ID"" = ""FilesetEntry"".""FileID"" AND ""File"".""BlocksetID"" = -100 AND ""Metadataset"".""ID""=""File"".""MetadataID"" AND ""Metadataset"".""BlocksetID"" = ""Blockset"".""ID"" ", (int)Library.Interface.ListChangesElementType.Folder); - var symlinks = string.Format(@"SELECT ""File"".""Path"" AS ""Path"", NULL AS ""FileHash"", ""Blockset"".""Fullhash"" AS ""MetaHash"", -1 AS ""Size"", {0} AS ""Type"", ""FilesetEntry"".""FilesetID"" AS ""FilesetID"" FROM ""File"",""FilesetEntry"",""Metadataset"",""Blockset"" WHERE ""File"".""ID"" = ""FilesetEntry"".""FileID"" AND ""File"".""BlocksetID"" = -200 AND ""Metadataset"".""ID""=""File"".""MetadataID"" AND ""Metadataset"".""BlocksetID"" = ""Blockset"".""ID"" ", (int)Library.Interface.ListChangesElementType.Symlink); - var files = string.Format(@"SELECT ""File"".""Path"" AS ""Path"", ""FB"".""FullHash"" AS ""FileHash"", ""MB"".""Fullhash"" AS ""MetaHash"", ""FB"".""Length"" AS ""Size"", {0} AS ""Type"", ""FilesetEntry"".""FilesetID"" AS ""FilesetID"" FROM ""File"",""FilesetEntry"",""Metadataset"",""Blockset"" MB, ""Blockset"" FB WHERE ""File"".""ID"" = ""FilesetEntry"".""FileID"" AND ""File"".""BlocksetID"" >= 0 AND ""Metadataset"".""ID""=""File"".""MetadataID"" AND ""Metadataset"".""BlocksetID"" = ""MB"".""ID"" AND ""File"".""BlocksetID"" = ""FB"".""ID"" ", (int)Library.Interface.ListChangesElementType.File); + + var folders = FormatInvariant($@"SELECT ""File"".""Path"" AS ""Path"", NULL AS ""FileHash"", ""Blockset"".""Fullhash"" AS ""MetaHash"", -1 AS ""Size"", {(int)Interface.ListChangesElementType.Folder} AS ""Type"", ""FilesetEntry"".""FilesetID"" AS ""FilesetID"" FROM ""File"",""FilesetEntry"",""Metadataset"",""Blockset"" WHERE ""File"".""ID"" = ""FilesetEntry"".""FileID"" AND ""File"".""BlocksetID"" = -100 AND ""Metadataset"".""ID""=""File"".""MetadataID"" AND ""Metadataset"".""BlocksetID"" = ""Blockset"".""ID"" "); + var symlinks = FormatInvariant($@"SELECT ""File"".""Path"" AS ""Path"", NULL AS ""FileHash"", ""Blockset"".""Fullhash"" AS ""MetaHash"", -1 AS ""Size"", {(int)Interface.ListChangesElementType.Symlink} AS ""Type"", ""FilesetEntry"".""FilesetID"" AS ""FilesetID"" FROM ""File"",""FilesetEntry"",""Metadataset"",""Blockset"" WHERE ""File"".""ID"" = ""FilesetEntry"".""FileID"" AND ""File"".""BlocksetID"" = -200 AND ""Metadataset"".""ID""=""File"".""MetadataID"" AND ""Metadataset"".""BlocksetID"" = ""Blockset"".""ID"" "); + var files = FormatInvariant($@"SELECT ""File"".""Path"" AS ""Path"", ""FB"".""FullHash"" AS ""FileHash"", ""MB"".""Fullhash"" AS ""MetaHash"", ""FB"".""Length"" AS ""Size"", {(int)Interface.ListChangesElementType.File} AS ""Type"", ""FilesetEntry"".""FilesetID"" AS ""FilesetID"" FROM ""File"",""FilesetEntry"",""Metadataset"",""Blockset"" MB, ""Blockset"" FB WHERE ""File"".""ID"" = ""FilesetEntry"".""FileID"" AND ""File"".""BlocksetID"" >= 0 AND ""Metadataset"".""ID""=""File"".""MetadataID"" AND ""Metadataset"".""BlocksetID"" = ""MB"".""ID"" AND ""File"".""BlocksetID"" = ""FB"".""ID"" "); var combined = "(" + folders + " UNION " + symlinks + " UNION " + files + ")"; - - - using(var cmd = m_connection.CreateCommand()) + + + using (var cmd = m_connection.CreateCommand(m_transaction)) { - cmd.Transaction = m_transaction; if (filter == null || filter.Empty) { // Simple case, select everything - cmd.ExecuteNonQuery(string.Format(@"INSERT INTO ""{0}"" (""Path"", ""FileHash"", ""MetaHash"", ""Size"", ""Type"") SELECT ""Path"", ""FileHash"", ""MetaHash"", ""Size"", ""Type"" FROM {1} A WHERE ""A"".""FilesetID"" = ? ", tablename, combined), filesetId); + cmd.ExecuteNonQuery(FormatInvariant($@"INSERT INTO ""{tablename}"" (""Path"", ""FileHash"", ""MetaHash"", ""Size"", ""Type"") SELECT ""Path"", ""FileHash"", ""MetaHash"", ""Size"", ""Type"" FROM {combined} A WHERE ""A"".""FilesetID"" = ? "), filesetId); } else if (Library.Utility.Utility.IsFSCaseSensitive && filter is FilterExpression expression && expression.Type == Duplicati.Library.Utility.FilterType.Simple) { @@ -155,11 +146,11 @@ namespace Duplicati.Library.Main.Database // SQLite only supports ASCII compares var p = expression.GetSimpleList(); var filenamestable = "Filenames-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" (""Path"" TEXT NOT NULL) ", filenamestable)); - cmd.CommandText = string.Format(@"INSERT INTO ""{0}"" (""Path"") VALUES (?)", filenamestable); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{filenamestable}"" (""Path"" TEXT NOT NULL) ")); + cmd.CommandText = FormatInvariant($@"INSERT INTO ""{filenamestable}"" (""Path"") VALUES (?)"); cmd.AddParameter(); - - foreach(var s in p) + + foreach (var s in p) { cmd.SetParameterValue(0, s); cmd.ExecuteNonQuery(); @@ -169,46 +160,40 @@ namespace Duplicati.Library.Main.Database if (expression.Result) { // Include filter - whereClause = string.Format(@"""A"".""FilesetID"" = ? AND ""A"".""Path"" IN (SELECT DISTINCT ""Path"" FROM ""{0}"")", filenamestable); + whereClause = FormatInvariant($@"""A"".""FilesetID"" = ? AND ""A"".""Path"" IN (SELECT DISTINCT ""Path"" FROM ""{filenamestable}"")"); } else { // Exclude filter - whereClause = string.Format(@"""A"".""FilesetID"" = ? AND ""A"".""Path"" NOT IN (SELECT DISTINCT ""Path"" FROM ""{0}"")", filenamestable); + whereClause = FormatInvariant($@"""A"".""FilesetID"" = ? AND ""A"".""Path"" NOT IN (SELECT DISTINCT ""Path"" FROM ""{filenamestable}"")"); } - cmd.ExecuteNonQuery(string.Format(@"INSERT INTO ""{0}"" (""Path"", ""FileHash"", ""MetaHash"", ""Size"", ""Type"") SELECT ""Path"", ""FileHash"", ""MetaHash"", ""Size"", ""Type"" FROM {1} A WHERE {2} ", tablename, combined, whereClause), filesetId); - cmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"" ", filenamestable)); + cmd.ExecuteNonQuery(FormatInvariant($@"INSERT INTO ""{tablename}"" (""Path"", ""FileHash"", ""MetaHash"", ""Size"", ""Type"") SELECT ""Path"", ""FileHash"", ""MetaHash"", ""Size"", ""Type"" FROM {combined} A WHERE {whereClause} "), filesetId); + cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{filenamestable}"" ")); } else { // Do row-wise iteration - object[] values = new object[5]; - using(var cmd2 = m_connection.CreateCommand()) - { - cmd2.CommandText = string.Format(@"INSERT INTO ""{0}"" (""Path"", ""FileHash"", ""MetaHash"", ""Size"", ""Type"") VALUES (?,?,?,?,?)", tablename); - cmd2.AddParameters(5); - cmd2.Transaction = m_transaction; - - using(var rd = cmd.ExecuteReader(string.Format(@"SELECT ""A"".""Path"", ""A"".""FileHash"", ""A"".""MetaHash"", ""A"".""Size"", ""A"".""Type"" FROM {0} A WHERE ""A"".""FilesetID"" = ?", combined), filesetId)) - while (rd.Read()) + var values = new object[5]; + using (var cmd2 = m_connection.CreateCommand(m_transaction, FormatInvariant($@"INSERT INTO ""{tablename}"" (""Path"", ""FileHash"", ""MetaHash"", ""Size"", ""Type"") VALUES (?,?,?,?,?)"))) + using (var rd = cmd.ExecuteReader(FormatInvariant($@"SELECT ""A"".""Path"", ""A"".""FileHash"", ""A"".""MetaHash"", ""A"".""Size"", ""A"".""Type"" FROM {combined} A WHERE ""A"".""FilesetID"" = ?"), filesetId)) + while (rd.Read()) + { + rd.GetValues(values); + if (values[0] != null && values[0] != DBNull.Value && FilterExpression.Matches(filter, values[0].ToString())) { - rd.GetValues(values); - if (values[0] != null && values[0] != DBNull.Value && Library.Utility.FilterExpression.Matches(filter, values[0].ToString())) - { - cmd2.SetParameterValue(0, values[0]); - cmd2.SetParameterValue(1, values[1]); - cmd2.SetParameterValue(2, values[2]); - cmd2.SetParameterValue(3, values[3]); - cmd2.SetParameterValue(4, values[4]); - cmd2.ExecuteNonQuery(); - } + cmd2.SetParameterValue(0, values[0]); + cmd2.SetParameterValue(1, values[1]); + cmd2.SetParameterValue(2, values[2]); + cmd2.SetParameterValue(3, values[3]); + cmd2.SetParameterValue(4, values[4]); + cmd2.ExecuteNonQuery(); } - } + } } } } - - public void AddElement(string path, string filehash, string metahash, long size, Library.Interface.ListChangesElementType type, bool asNew) + + public void AddElement(string path, string filehash, string metahash, long size, Interface.ListChangesElementType type, bool asNew) { var cmd = asNew ? m_insertCurrentElementCommand : m_insertPreviousElementCommand; cmd.SetParameterValue(0, path); @@ -219,9 +204,9 @@ namespace Duplicati.Library.Main.Database cmd.ExecuteNonQuery(); } - private static IEnumerable ReaderToStringList(System.Data.IDataReader rd) + private static IEnumerable ReaderToStringList(IDataReader rd) { - using(rd) + using (rd) while (rd.Read()) { var v = rd.GetValue(0); @@ -231,13 +216,13 @@ namespace Duplicati.Library.Main.Database yield return v.ToString(); } } - + private Tuple GetSqls(bool allTypes) { return new Tuple( - string.Format(@"SELECT ""Path"" FROM ""{0}"" WHERE {2} ""{0}"".""Path"" NOT IN (SELECT ""Path"" FROM ""{1}"")", m_currentTable, m_previousTable, string.Format(allTypes ? "" : @" ""{0}"".""Type"" = ? AND ", m_currentTable)), - string.Format(@"SELECT ""Path"" FROM ""{0}"" WHERE {2} ""{0}"".""Path"" NOT IN (SELECT ""Path"" FROM ""{1}"")", m_previousTable, m_currentTable, string.Format(allTypes ? "" : @" ""{0}"".""Type"" = ? AND ", m_previousTable)), - string.Format(@"SELECT ""{0}"".""Path"" FROM ""{0}"",""{1}"" WHERE {2} ""{0}"".""Path"" = ""{1}"".""Path"" AND (""{0}"".""FileHash"" != ""{1}"".""FileHash"" OR ""{0}"".""MetaHash"" != ""{1}"".""MetaHash"" OR ""{0}"".""Type"" != ""{1}"".""Type"") ", m_currentTable, m_previousTable, string.Format(allTypes ? "" : @" ""{0}"".""Type"" = ? AND ", m_currentTable)) + FormatInvariant($@"SELECT ""Path"" FROM ""{m_currentTable}"" WHERE {(allTypes ? "" : FormatInvariant(@$" ""{m_currentTable}"".""Type"" = ? AND "))} ""{m_currentTable}"".""Path"" NOT IN (SELECT ""Path"" FROM ""{m_previousTable}"")"), + FormatInvariant($@"SELECT ""Path"" FROM ""{m_previousTable}"" WHERE {(allTypes ? "" : FormatInvariant(@$" ""{m_previousTable}"".""Type"" = ? AND "))} ""{m_previousTable}"".""Path"" NOT IN (SELECT ""Path"" FROM ""{m_currentTable}"")"), + FormatInvariant($@"SELECT ""{m_currentTable}"".""Path"" FROM ""{m_currentTable}"",""{m_previousTable}"" WHERE {(allTypes ? "" : FormatInvariant($@" ""{m_currentTable}"".""Type"" = ? AND "))} ""{m_currentTable}"".""Path"" = ""{m_previousTable}"".""Path"" AND (""{m_currentTable}"".""FileHash"" != ""{m_previousTable}"".""FileHash"" OR ""{m_currentTable}"".""MetaHash"" != ""{m_previousTable}"".""MetaHash"" OR ""{m_currentTable}"".""Type"" != ""{m_previousTable}"".""Type"") ") ); } @@ -247,114 +232,109 @@ namespace Duplicati.Library.Main.Database var added = sqls.Item1; var deleted = sqls.Item2; //var modified = sqls.Item3; - - using(var cmd = m_connection.CreateCommand()) + + using (var cmd = m_connection.CreateCommand(m_transaction)) { - cmd.Transaction = m_transaction; - var result = new ChangeSizeReport(); - - result.PreviousSize = cmd.ExecuteScalarInt64(string.Format(@"SELECT SUM(""Size"") FROM ""{0}"" ", m_previousTable), 0); - result.CurrentSize = cmd.ExecuteScalarInt64(string.Format(@"SELECT SUM(""Size"") FROM ""{0}"" ", m_currentTable), 0); - - result.AddedSize = cmd.ExecuteScalarInt64(string.Format(@"SELECT SUM(""Size"") FROM ""{0}"" WHERE ""{0}"".""Path"" IN ({1}) ", m_currentTable, added), 0); - result.DeletedSize = cmd.ExecuteScalarInt64(string.Format(@"SELECT SUM(""Size"") FROM ""{0}"" WHERE ""{0}"".""Path"" IN ({1}) ", m_previousTable, deleted), 0); - + + result.PreviousSize = cmd.ExecuteScalarInt64(FormatInvariant($@"SELECT SUM(""Size"") FROM ""{m_previousTable}"" "), 0); + result.CurrentSize = cmd.ExecuteScalarInt64(FormatInvariant($@"SELECT SUM(""Size"") FROM ""{m_currentTable}"" "), 0); + + result.AddedSize = cmd.ExecuteScalarInt64(FormatInvariant($@"SELECT SUM(""Size"") FROM ""{m_currentTable}"" WHERE ""{m_currentTable}"".""Path"" IN ({added}) "), 0); + result.DeletedSize = cmd.ExecuteScalarInt64(FormatInvariant($@"SELECT SUM(""Size"") FROM ""{m_previousTable}"" WHERE ""{m_previousTable}"".""Path"" IN ({deleted}) "), 0); + return result; } } - + public IChangeCountReport CreateChangeCountReport() { var sqls = GetSqls(false); var added = @"SELECT COUNT(*) FROM (" + sqls.Item1 + ")"; var deleted = @"SELECT COUNT(*) FROM (" + sqls.Item2 + ")"; var modified = @"SELECT COUNT(*) FROM (" + sqls.Item3 + ")"; - - using(var cmd = m_connection.CreateCommand()) - { - cmd.Transaction = m_transaction; - - var result = new ChangeCountReport(); - result.AddedFolders = cmd.ExecuteScalarInt64(added, 0, (int)Library.Interface.ListChangesElementType.Folder); - result.AddedSymlinks = cmd.ExecuteScalarInt64(added, 0, (int)Library.Interface.ListChangesElementType.Symlink); - result.AddedFiles = cmd.ExecuteScalarInt64(added, 0, (int)Library.Interface.ListChangesElementType.File); - result.DeletedFolders = cmd.ExecuteScalarInt64(deleted, 0, (int)Library.Interface.ListChangesElementType.Folder); - result.DeletedSymlinks = cmd.ExecuteScalarInt64(deleted, 0, (int)Library.Interface.ListChangesElementType.Symlink); - result.DeletedFiles = cmd.ExecuteScalarInt64(deleted, 0, (int)Library.Interface.ListChangesElementType.File); - - result.ModifiedFolders = cmd.ExecuteScalarInt64(modified, 0, (int)Library.Interface.ListChangesElementType.Folder); - result.ModifiedSymlinks = cmd.ExecuteScalarInt64(modified, 0, (int)Library.Interface.ListChangesElementType.Symlink); - result.ModifiedFiles = cmd.ExecuteScalarInt64(modified, 0, (int)Library.Interface.ListChangesElementType.File); - + using (var cmd = m_connection.CreateCommand(m_transaction)) + { + var result = new ChangeCountReport(); + result.AddedFolders = cmd.ExecuteScalarInt64(added, 0, (int)Interface.ListChangesElementType.Folder); + result.AddedSymlinks = cmd.ExecuteScalarInt64(added, 0, (int)Interface.ListChangesElementType.Symlink); + result.AddedFiles = cmd.ExecuteScalarInt64(added, 0, (int)Interface.ListChangesElementType.File); + + result.DeletedFolders = cmd.ExecuteScalarInt64(deleted, 0, (int)Interface.ListChangesElementType.Folder); + result.DeletedSymlinks = cmd.ExecuteScalarInt64(deleted, 0, (int)Interface.ListChangesElementType.Symlink); + result.DeletedFiles = cmd.ExecuteScalarInt64(deleted, 0, (int)Interface.ListChangesElementType.File); + + result.ModifiedFolders = cmd.ExecuteScalarInt64(modified, 0, (int)Interface.ListChangesElementType.Folder); + result.ModifiedSymlinks = cmd.ExecuteScalarInt64(modified, 0, (int)Interface.ListChangesElementType.Symlink); + result.ModifiedFiles = cmd.ExecuteScalarInt64(modified, 0, (int)Interface.ListChangesElementType.File); + return result; - } + } } - - public IEnumerable> CreateChangedFileReport() + + public IEnumerable> CreateChangedFileReport() { var sqls = GetSqls(false); var added = sqls.Item1; var deleted = sqls.Item2; var modified = sqls.Item3; - - using(var cmd = m_connection.CreateCommand()) + + using (var cmd = m_connection.CreateCommand(m_transaction)) { - cmd.Transaction = m_transaction; - foreach(var s in ReaderToStringList(cmd.ExecuteReader(added, (int)Library.Interface.ListChangesElementType.Folder))) - yield return new Tuple(Library.Interface.ListChangesChangeType.Added, Library.Interface.ListChangesElementType.Folder, s); - foreach(var s in ReaderToStringList(cmd.ExecuteReader(added, (int)Library.Interface.ListChangesElementType.Symlink))) - yield return new Tuple(Library.Interface.ListChangesChangeType.Added, Library.Interface.ListChangesElementType.Symlink, s); - foreach(var s in ReaderToStringList(cmd.ExecuteReader(added, (int)Library.Interface.ListChangesElementType.File))) - yield return new Tuple(Library.Interface.ListChangesChangeType.Added, Library.Interface.ListChangesElementType.File, s); - - foreach(var s in ReaderToStringList(cmd.ExecuteReader(deleted, (int)Library.Interface.ListChangesElementType.Folder))) - yield return new Tuple(Library.Interface.ListChangesChangeType.Deleted, Library.Interface.ListChangesElementType.Folder, s); - foreach(var s in ReaderToStringList(cmd.ExecuteReader(deleted, (int)Library.Interface.ListChangesElementType.Symlink))) - yield return new Tuple(Library.Interface.ListChangesChangeType.Deleted, Library.Interface.ListChangesElementType.Symlink, s); - foreach(var s in ReaderToStringList(cmd.ExecuteReader(deleted, (int)Library.Interface.ListChangesElementType.File))) - yield return new Tuple(Library.Interface.ListChangesChangeType.Deleted, Library.Interface.ListChangesElementType.File, s); - - foreach(var s in ReaderToStringList(cmd.ExecuteReader(modified, (int)Library.Interface.ListChangesElementType.Folder))) - yield return new Tuple(Library.Interface.ListChangesChangeType.Modified, Library.Interface.ListChangesElementType.Folder, s); - foreach(var s in ReaderToStringList(cmd.ExecuteReader(modified, (int)Library.Interface.ListChangesElementType.Symlink))) - yield return new Tuple(Library.Interface.ListChangesChangeType.Modified, Library.Interface.ListChangesElementType.Symlink, s); - foreach(var s in ReaderToStringList(cmd.ExecuteReader(modified, (int)Library.Interface.ListChangesElementType.File))) - yield return new Tuple(Library.Interface.ListChangesChangeType.Modified, Library.Interface.ListChangesElementType.File, s); + foreach (var s in ReaderToStringList(cmd.ExecuteReader(added, (int)Interface.ListChangesElementType.Folder))) + yield return new Tuple(Interface.ListChangesChangeType.Added, Interface.ListChangesElementType.Folder, s); + foreach (var s in ReaderToStringList(cmd.ExecuteReader(added, (int)Interface.ListChangesElementType.Symlink))) + yield return new Tuple(Interface.ListChangesChangeType.Added, Interface.ListChangesElementType.Symlink, s); + foreach (var s in ReaderToStringList(cmd.ExecuteReader(added, (int)Interface.ListChangesElementType.File))) + yield return new Tuple(Interface.ListChangesChangeType.Added, Interface.ListChangesElementType.File, s); + + foreach (var s in ReaderToStringList(cmd.ExecuteReader(deleted, (int)Interface.ListChangesElementType.Folder))) + yield return new Tuple(Interface.ListChangesChangeType.Deleted, Interface.ListChangesElementType.Folder, s); + foreach (var s in ReaderToStringList(cmd.ExecuteReader(deleted, (int)Interface.ListChangesElementType.Symlink))) + yield return new Tuple(Interface.ListChangesChangeType.Deleted, Interface.ListChangesElementType.Symlink, s); + foreach (var s in ReaderToStringList(cmd.ExecuteReader(deleted, (int)Interface.ListChangesElementType.File))) + yield return new Tuple(Interface.ListChangesChangeType.Deleted, Interface.ListChangesElementType.File, s); + + foreach (var s in ReaderToStringList(cmd.ExecuteReader(modified, (int)Interface.ListChangesElementType.Folder))) + yield return new Tuple(Interface.ListChangesChangeType.Modified, Interface.ListChangesElementType.Folder, s); + foreach (var s in ReaderToStringList(cmd.ExecuteReader(modified, (int)Interface.ListChangesElementType.Symlink))) + yield return new Tuple(Interface.ListChangesChangeType.Modified, Interface.ListChangesElementType.Symlink, s); + foreach (var s in ReaderToStringList(cmd.ExecuteReader(modified, (int)Interface.ListChangesElementType.File))) + yield return new Tuple(Interface.ListChangesChangeType.Modified, Interface.ListChangesElementType.File, s); } } - + public void Dispose() { if (m_insertPreviousElementCommand != null) { try { m_insertPreviousElementCommand.Dispose(); } - catch {} + catch { } finally { m_insertPreviousElementCommand = null; } } if (m_insertCurrentElementCommand != null) { try { m_insertCurrentElementCommand.Dispose(); } - catch {} + catch { } finally { m_insertCurrentElementCommand = null; } } - + if (m_transaction != null) { try { m_transaction.Rollback(); } - catch {} + catch { } finally { m_previousTable = null; - m_currentTable= null; + m_currentTable = null; m_transaction = null; } } } } - + public IStorageHelper CreateStorageHelper() { return new StorageHelper(m_connection); diff --git a/Duplicati/Library/Main/Database/LocalListDatabase.cs b/Duplicati/Library/Main/Database/LocalListDatabase.cs index c602ee629..b13208ef8 100644 --- a/Duplicati/Library/Main/Database/LocalListDatabase.cs +++ b/Duplicati/Library/Main/Database/LocalListDatabase.cs @@ -1,29 +1,30 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; +using System.Data; using System.Linq; using System.Collections.Generic; -using System.Text; using Duplicati.Library.Common.IO; +using Duplicati.Library.Utility; namespace Duplicati.Library.Main.Database { @@ -34,13 +35,13 @@ namespace Duplicati.Library.Main.Database { ShouldCloseConnection = true; } - + public interface IFileversion { string Path { get; } IEnumerable Sizes { get; } } - + public interface IFileset { long Version { get; } @@ -49,39 +50,39 @@ namespace Duplicati.Library.Main.Database long FileCount { get; } long FileSizes { get; } } - + public interface IFileSets : IDisposable { IEnumerable Sets { get; } IEnumerable QuickSets { get; } - IEnumerable SelectFiles(Library.Utility.IFilter filter); - IEnumerable GetLargestPrefix(Library.Utility.IFilter filter); - IEnumerable SelectFolderContents(Library.Utility.IFilter filter); - void TakeFirst (); + IEnumerable SelectFiles(IFilter filter); + IEnumerable GetLargestPrefix(IFilter filter); + IEnumerable SelectFolderContents(IFilter filter); + void TakeFirst(); } private class FileSets : IFileSets { - private readonly System.Data.IDbConnection m_connection; + private readonly IDbConnection m_connection; private string m_tablename; private readonly KeyValuePair[] m_filesets; - + public FileSets(LocalListDatabase owner, DateTime time, long[] versions) { m_connection = owner.m_connection; m_filesets = owner.FilesetTimes.ToArray(); m_tablename = "Filesets-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); var tmp = owner.GetFilelistWhereClause(time, versions, m_filesets); - string query = tmp.Item1; + var query = tmp.Item1; var args = tmp.Item2; - - using(var cmd = m_connection.CreateCommand()) + + using (var cmd = m_connection.CreateCommand()) { - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" AS SELECT DISTINCT ""ID"" AS ""FilesetID"", ""IsFullBackup"" AS ""IsFullBackup"" , ""Timestamp"" AS ""Timestamp"" FROM ""Fileset"" " + query, m_tablename), args); - cmd.ExecuteNonQuery(string.Format(@"CREATE INDEX ""{0}_FilesetIDTimestampIndex"" ON ""{0}"" (""FilesetID"", ""Timestamp"" DESC)", m_tablename)); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{m_tablename}"" AS SELECT DISTINCT ""ID"" AS ""FilesetID"", ""IsFullBackup"" AS ""IsFullBackup"" , ""Timestamp"" AS ""Timestamp"" FROM ""Fileset"" {query}"), args); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE INDEX ""{m_tablename}_FilesetIDTimestampIndex"" ON ""{m_tablename}"" (""FilesetID"", ""Timestamp"" DESC)")); } } - + private class Fileset : IFileset { public long Version { get; private set; } @@ -89,90 +90,90 @@ namespace Duplicati.Library.Main.Database public DateTime Time { get; private set; } public long FileCount { get; private set; } public long FileSizes { get; private set; } - + public Fileset(long version, int isFullBackup, DateTime time, long filecount, long filesizes) { - this.Version = version; - this.IsFullBackup = isFullBackup; - this.Time = time; - this.FileCount = filecount; - this.FileSizes = filesizes; + Version = version; + IsFullBackup = isFullBackup; + Time = time; + FileCount = filecount; + FileSizes = filesizes; } } - + private class Fileversion : IFileversion { - private readonly System.Data.IDataReader m_reader; + private readonly IDataReader m_reader; public string Path { get; private set; } public bool More { get; private set; } - - public Fileversion(System.Data.IDataReader reader) - { - m_reader = reader; - this.Path = reader.GetValue(0).ToString(); - this.More = true; + + public Fileversion(IDataReader reader) + { + m_reader = reader; + Path = reader.GetValue(0).ToString(); + More = true; } - + public IEnumerable Sizes { get { - while(this.More && this.Path == m_reader.GetValue(0).ToString()) + while (More && Path == m_reader.GetValue(0).ToString()) { yield return m_reader.ConvertValueToInt64(1, -1); - this.More = m_reader.Read(); + More = m_reader.Read(); } } } } - + private class FileversionFixed : IFileversion { public string Path { get; internal set; } public IEnumerable Sizes { get { return new long[0]; } } } - public IEnumerable GetLargestPrefix(Library.Utility.IFilter filter) + public IEnumerable GetLargestPrefix(IFilter filter) { return GetLargestPrefix(filter, null); } - private IEnumerable GetLargestPrefix(Library.Utility.IFilter filter, string prefixrule) + private IEnumerable GetLargestPrefix(IFilter filter, string prefixrule) { - using(var tmpnames = new FilteredFilenameTable(m_connection, filter, null)) - using(var cmd = m_connection.CreateCommand()) + using (var tmpnames = new FilteredFilenameTable(m_connection, filter, null)) + using (var cmd = m_connection.CreateCommand()) { //First we trim the filelist to exclude filenames not found in any of the filesets - cmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""{0}"" WHERE ""Path"" NOT IN (SELECT DISTINCT ""Path"" FROM ""File"", ""FilesetEntry"" WHERE ""FilesetEntry"".""FileID"" = ""File"".""ID"" AND ""FilesetEntry"".""FilesetID"" IN (SELECT ""FilesetID"" FROM ""{1}"") ) ", tmpnames.Tablename, m_tablename)); + cmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""{tmpnames.Tablename}"" WHERE ""Path"" NOT IN (SELECT DISTINCT ""Path"" FROM ""File"", ""FilesetEntry"" WHERE ""FilesetEntry"".""FileID"" = ""File"".""ID"" AND ""FilesetEntry"".""FilesetID"" IN (SELECT ""FilesetID"" FROM ""{m_tablename}"") ) ")); //If we have a prefix rule, apply it if (!string.IsNullOrWhiteSpace(prefixrule)) - cmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""{0}"" WHERE SUBSTR(""Path"", 1, {1}) != ?", tmpnames.Tablename, prefixrule.Length), prefixrule); + cmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""{tmpnames.Tablename}"" WHERE SUBSTR(""Path"", 1, {prefixrule.Length}) != ?"), prefixrule); // Then we recursively find the largest prefix - cmd.CommandText = string.Format(@"SELECT ""Path"" FROM ""{0}"" ORDER BY LENGTH(""Path"") DESC LIMIT 1", tmpnames.Tablename); + cmd.CommandText = FormatInvariant($@"SELECT ""Path"" FROM ""{tmpnames.Tablename}"" ORDER BY LENGTH(""Path"") DESC LIMIT 1"); var v0 = cmd.ExecuteScalar(); - string maxpath = ""; + var maxpath = ""; if (v0 != null) maxpath = v0.ToString(); var dirsep = Util.GuessDirSeparator(maxpath); - - cmd.CommandText = string.Format(@"SELECT COUNT(*) FROM ""{0}""", tmpnames.Tablename); + + cmd.CommandText = FormatInvariant($@"SELECT COUNT(*) FROM ""{tmpnames.Tablename}"""); var filecount = cmd.ExecuteScalarInt64(0); - long foundfiles = -1; - + var foundfiles = -1L; + //TODO: Handle FS case-sensitive? - cmd.CommandText = string.Format(@"SELECT COUNT(*) FROM ""{0}"" WHERE SUBSTR(""Path"", 1, ?) = ?", tmpnames.Tablename); + cmd.CommandText = FormatInvariant($@"SELECT COUNT(*) FROM ""{tmpnames.Tablename}"" WHERE SUBSTR(""Path"", 1, ?) = ?"); cmd.AddParameter(); cmd.AddParameter(); - + while (filecount != foundfiles && maxpath.Length > 0) { var mp = Util.AppendDirSeparator(maxpath, dirsep); cmd.SetParameterValue(0, mp.Length); cmd.SetParameterValue(1, mp); - + foundfiles = cmd.ExecuteScalarInt64(0); if (filecount != foundfiles) @@ -189,7 +190,7 @@ namespace Duplicati.Library.Main.Database // Special handling for Windows and multi-drive/UNC backups as they do not have a single common root if (string.IsNullOrWhiteSpace(maxpath) && string.IsNullOrWhiteSpace(prefixrule)) { - var paths = cmd.ExecuteReaderEnumerable(string.Format(@"SELECT Path FROM ""{0}""", tmpnames.Tablename)).Select(x => x.ConvertValueToString(0)).ToArray(); + var paths = cmd.ExecuteReaderEnumerable(FormatInvariant($@"SELECT Path FROM ""{tmpnames.Tablename}""")).Select(x => x.ConvertValueToString(0)).ToArray(); var roots = paths.Select(x => x.Substring(0, 1)).Distinct().Where(x => x != "\\").ToArray(); //unc path like \\server.domain\ @@ -199,20 +200,17 @@ namespace Duplicati.Library.Main.Database return roots.Concat(rootsUNC).Select(x => GetLargestPrefix(filter, x).First()).Distinct().ToArray(); } - return - new IFileversion[] { - new FileversionFixed { Path = maxpath == "" ? "" : Util.AppendDirSeparator(maxpath, dirsep) } - }; + return [new FileversionFixed { Path = maxpath == "" ? "" : Util.AppendDirSeparator(maxpath, dirsep) }]; } } - - private IEnumerable SelectFolderEntries(System.Data.IDbCommand cmd, string prefix, string table) + + private IEnumerable SelectFolderEntries(IDbCommand cmd, string prefix, string table) { if (!string.IsNullOrEmpty(prefix)) prefix = Util.AppendDirSeparator(prefix, Util.GuessDirSeparator(prefix)); - + var ppl = prefix.Length; - using(var rd = cmd.ExecuteReader(string.Format(@"SELECT DISTINCT ""Path"" FROM ""{0}"" ", table))) + using (var rd = cmd.ExecuteReader(FormatInvariant($@"SELECT DISTINCT ""Path"" FROM ""{table}"" "))) while (rd.Read()) { var s = rd.GetString(0); @@ -228,8 +226,8 @@ namespace Duplicati.Library.Main.Database yield return prefix + s; } } - - public IEnumerable SelectFolderContents(Library.Utility.IFilter filter) + + public IEnumerable SelectFolderContents(IFilter filter) { var tbname = "Filenames-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); try @@ -237,22 +235,22 @@ namespace Duplicati.Library.Main.Database string pathprefix; if (filter == null || filter.Empty) pathprefix = ""; - else if (filter as Library.Utility.FilterExpression == null || ((Library.Utility.FilterExpression)filter).Type != Duplicati.Library.Utility.FilterType.Simple || ((Library.Utility.FilterExpression)filter).GetSimpleList().Length != 1) + else if (filter as FilterExpression == null || ((FilterExpression)filter).Type != FilterType.Simple || ((FilterExpression)filter).GetSimpleList().Length != 1) throw new ArgumentException("Filter for list-folder-contents must be a path prefix with no wildcards", nameof(filter)); else - pathprefix = ((Library.Utility.FilterExpression)filter).GetSimpleList().First(); + pathprefix = ((FilterExpression)filter).GetSimpleList().First(); var dirsep = Util.GuessDirSeparator(pathprefix); if (pathprefix.Length > 0 || dirsep == "/") pathprefix = Util.AppendDirSeparator(pathprefix, dirsep); - - using(var tmpnames = new FilteredFilenameTable(m_connection, new Library.Utility.FilterExpression(new string[] { pathprefix + "*" }, true), null)) - using(var cmd = m_connection.CreateCommand()) + + using (var tmpnames = new FilteredFilenameTable(m_connection, new FilterExpression(new string[] { pathprefix + "*" }, true), null)) + using (var cmd = m_connection.CreateCommand()) { //First we trim the filelist to exclude filenames not found in any of the filesets - cmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""{0}"" WHERE ""Path"" NOT IN (SELECT DISTINCT ""Path"" FROM ""File"", ""FilesetEntry"" WHERE ""FilesetEntry"".""FileID"" = ""File"".""ID"" AND ""FilesetEntry"".""FilesetID"" IN (SELECT ""FilesetID"" FROM ""{1}"") ) ", tmpnames.Tablename, m_tablename)); - + cmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""{tmpnames.Tablename}"" WHERE ""Path"" NOT IN (SELECT DISTINCT ""Path"" FROM ""File"", ""FilesetEntry"" WHERE ""FilesetEntry"".""FileID"" = ""File"".""ID"" AND ""FilesetEntry"".""FilesetID"" IN (SELECT ""FilesetID"" FROM ""{m_tablename}"") ) ")); + // If we had instr support this would work: /*var distinctPaths = @"SELECT DISTINCT :1 || " + @"CASE(INSTR(SUBSTR(""Path"", :2), '/')) " + @@ -262,33 +260,33 @@ namespace Duplicati.Library.Main.Database @" FROM (" + cartesianPathFileset + @")";*/ // Instead we manually iterate the paths - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" (""Path"" TEXT NOT NULL)", tbname)); - - using(var c2 = m_connection.CreateCommand()) + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{tbname}"" (""Path"" TEXT NOT NULL)")); + + using (var c2 = m_connection.CreateCommand()) { - c2.CommandText = string.Format(@"INSERT INTO ""{0}"" (""Path"") VALUES (?)", tbname); + c2.CommandText = FormatInvariant($@"INSERT INTO ""{tbname}"" (""Path"") VALUES (?)"); c2.AddParameter(); - - foreach(var n in SelectFolderEntries(cmd, pathprefix, tmpnames.Tablename).Distinct()) + + foreach (var n in SelectFolderEntries(cmd, pathprefix, tmpnames.Tablename).Distinct()) { c2.SetParameterValue(0, n); c2.ExecuteNonQuery(); } - c2.ExecuteNonQuery(string.Format(@"CREATE INDEX ""{0}_PathIndex"" ON ""{0}"" (""Path"")", tbname)); + c2.ExecuteNonQuery(FormatInvariant($@"CREATE INDEX ""{tbname}_PathIndex"" ON ""{tbname}"" (""Path"")")); } - - //Then we select the matching results - var filesets = string.Format(@"SELECT ""FilesetID"", ""Timestamp"" FROM ""{0}"" ORDER BY ""Timestamp"" DESC", m_tablename); - var cartesianPathFileset = string.Format(@"SELECT ""A"".""Path"", ""B"".""FilesetID"" FROM ""{0}"" A, (" + filesets + @") B ORDER BY ""A"".""Path"" ASC, ""B"".""Timestamp"" DESC", tbname, m_tablename); - var filesWithSizes = string.Format(@"SELECT ""Length"", ""FilesetEntry"".""FilesetID"", ""File"".""Path"" FROM ""Blockset"", ""FilesetEntry"", ""File"" WHERE ""File"".""BlocksetID"" = ""Blockset"".""ID"" AND ""FilesetEntry"".""FileID"" = ""File"".""ID"" AND FilesetEntry.""FilesetID"" IN (SELECT DISTINCT ""FilesetID"" FROM ""{0}"") ", m_tablename); + //Then we select the matching results + var filesets = FormatInvariant($@"SELECT ""FilesetID"", ""Timestamp"" FROM ""{m_tablename}"" ORDER BY ""Timestamp"" DESC"); + var cartesianPathFileset = FormatInvariant($@"SELECT ""A"".""Path"", ""B"".""FilesetID"" FROM ""{tbname}"" A, ({filesets}) B ORDER BY ""A"".""Path"" ASC, ""B"".""Timestamp"" DESC"); + + var filesWithSizes = FormatInvariant($@"SELECT ""Length"", ""FilesetEntry"".""FilesetID"", ""File"".""Path"" FROM ""Blockset"", ""FilesetEntry"", ""File"" WHERE ""File"".""BlocksetID"" = ""Blockset"".""ID"" AND ""FilesetEntry"".""FileID"" = ""File"".""ID"" AND FilesetEntry.""FilesetID"" IN (SELECT DISTINCT ""FilesetID"" FROM ""{m_tablename}"") "); var query = @"SELECT ""C"".""Path"", ""D"".""Length"", ""C"".""FilesetID"" FROM (" + cartesianPathFileset + @") C LEFT OUTER JOIN (" + filesWithSizes + @") D ON ""C"".""FilesetID"" = ""D"".""FilesetID"" AND ""C"".""Path"" = ""D"".""Path"""; - - cmd.AddParameter(pathprefix, "1"); + + cmd.AddParameter(pathprefix, "1"); cmd.AddParameter(pathprefix.Length + 1, "2"); - - using(var rd = cmd.ExecuteReader(query)) + + using (var rd = cmd.ExecuteReader(query)) if (rd.Read()) { bool more; @@ -304,39 +302,39 @@ namespace Duplicati.Library.Main.Database { more = rd.Read(); } - - } while(more); + + } while (more); } - } + } } finally { try { - using(var c = m_connection.CreateCommand()) - c.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}""", tbname)); + using (var c = m_connection.CreateCommand()) + c.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{tbname}""")); } catch { } } } - - public IEnumerable SelectFiles(Library.Utility.IFilter filter) + + public IEnumerable SelectFiles(IFilter filter) { - using(var tmpnames = new FilteredFilenameTable(m_connection, filter, null)) - using(var cmd = m_connection.CreateCommand()) + using (var tmpnames = new FilteredFilenameTable(m_connection, filter, null)) + using (var cmd = m_connection.CreateCommand()) { //First we trim the filelist to exclude filenames not found in any of the filesets - cmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""{0}"" WHERE ""Path"" NOT IN (SELECT DISTINCT ""Path"" FROM ""File"", ""FilesetEntry"" WHERE ""FilesetEntry"".""FileID"" = ""File"".""ID"" AND ""FilesetEntry"".""FilesetID"" IN (SELECT ""FilesetID"" FROM ""{1}"") ) ", tmpnames.Tablename, m_tablename)); - + cmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""{tmpnames.Tablename}"" WHERE ""Path"" NOT IN (SELECT DISTINCT ""Path"" FROM ""File"", ""FilesetEntry"" WHERE ""FilesetEntry"".""FileID"" = ""File"".""ID"" AND ""FilesetEntry"".""FilesetID"" IN (SELECT ""FilesetID"" FROM ""{m_tablename}"") ) ")); + //Then we select the matching results - var filesets = string.Format(@"SELECT ""FilesetID"", ""Timestamp"" FROM ""{0}"" ORDER BY ""Timestamp"" DESC", m_tablename); - var cartesianPathFileset = string.Format(@"SELECT ""A"".""Path"", ""B"".""FilesetID"" FROM ""{0}"" A, (" + filesets + @") B ORDER BY ""A"".""Path"" ASC, ""B"".""Timestamp"" DESC", tmpnames.Tablename, m_tablename); - var filesWithSizes = string.Format(@"SELECT ""Length"", ""FilesetEntry"".""FilesetID"", ""File"".""Path"" FROM ""Blockset"", ""FilesetEntry"", ""File"" WHERE ""File"".""BlocksetID"" = ""Blockset"".""ID"" AND ""FilesetEntry"".""FileID"" = ""File"".""ID"" AND FilesetEntry.""FilesetID"" IN (SELECT DISTINCT ""FilesetID"" FROM ""{0}"") ", m_tablename); + var filesets = FormatInvariant($@"SELECT ""FilesetID"", ""Timestamp"" FROM ""{m_tablename}"" ORDER BY ""Timestamp"" DESC"); + var cartesianPathFileset = FormatInvariant($@"SELECT ""A"".""Path"", ""B"".""FilesetID"" FROM ""{tmpnames.Tablename}"" A, ({filesets}) B ORDER BY ""A"".""Path"" ASC, ""B"".""Timestamp"" DESC"); + var filesWithSizes = FormatInvariant($@"SELECT ""Length"", ""FilesetEntry"".""FilesetID"", ""File"".""Path"" FROM ""Blockset"", ""FilesetEntry"", ""File"" WHERE ""File"".""BlocksetID"" = ""Blockset"".""ID"" AND ""FilesetEntry"".""FileID"" = ""File"".""ID"" AND FilesetEntry.""FilesetID"" IN (SELECT DISTINCT ""FilesetID"" FROM ""{m_tablename}"") "); var query = @"SELECT ""C"".""Path"", ""D"".""Length"", ""C"".""FilesetID"" FROM (" + cartesianPathFileset + @") C LEFT OUTER JOIN (" + filesWithSizes + @") D ON ""C"".""FilesetID"" = ""D"".""FilesetID"" AND ""C"".""Path"" = ""D"".""Path"""; - using(var rd = cmd.ExecuteReader(query)) - if(rd.Read()) + using (var rd = cmd.ExecuteReader(query)) + if (rd.Read()) { bool more; do @@ -344,15 +342,15 @@ namespace Duplicati.Library.Main.Database var f = new Fileversion(rd); yield return f; more = f.More; - } while(more); + } while (more); } } } - + public void TakeFirst() { - using(var cmd = m_connection.CreateCommand()) - cmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""{0}"" WHERE ""FilesetID"" NOT IN (SELECT ""FilesetID"" FROM ""{0}"" ORDER BY ""Timestamp"" DESC LIMIT 1 )", m_tablename)); + using (var cmd = m_connection.CreateCommand()) + cmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""{m_tablename}"" WHERE ""FilesetID"" NOT IN (SELECT ""FilesetID"" FROM ""{m_tablename}"" ORDER BY ""Timestamp"" DESC LIMIT 1 )")); } public IEnumerable QuickSets @@ -361,83 +359,69 @@ namespace Duplicati.Library.Main.Database { var dict = new Dictionary(); for (var i = 0; i < m_filesets.Length; i++) - { dict[m_filesets[i].Key] = i; - } using (var cmd = m_connection.CreateCommand()) - { - using (var rd = cmd.ExecuteReader(@"SELECT DISTINCT ""ID"", ""IsFullBackup"" FROM ""Fileset"" ORDER BY ""Timestamp"" DESC ")) + using (var rd = cmd.ExecuteReader(@"SELECT DISTINCT ""ID"", ""IsFullBackup"" FROM ""Fileset"" ORDER BY ""Timestamp"" DESC ")) + while (rd.Read()) { - while (rd.Read()) - { - var id = rd.GetInt64(0); - var backupType = rd.GetInt32(1); - var e = dict[id]; - yield return new Fileset(e, backupType, m_filesets[e].Value, -1L, -1L); - } + var id = rd.GetInt64(0); + var backupType = rd.GetInt32(1); + var e = dict[id]; + yield return new Fileset(e, backupType, m_filesets[e].Value, -1L, -1L); } - } } } - public IEnumerable Sets - { + public IEnumerable Sets + { get { var dict = new Dictionary(); for (var i = 0; i < m_filesets.Length; i++) - { dict[m_filesets[i].Key] = i; - } - - var summation = - $@"SELECT ""A"".""FilesetID"" AS ""FilesetID"", COUNT(*) AS ""FileCount"", SUM(""C"".""Length"") AS ""FileSizes"" FROM ""FilesetEntry"" A, ""File"" B, ""Blockset"" C WHERE ""A"".""FileID"" = ""B"".""ID"" AND ""B"".""BlocksetID"" = ""C"".""ID"" AND ""A"".""FilesetID"" IN (SELECT DISTINCT ""FilesetID"" FROM ""{m_tablename}"") GROUP BY ""A"".""FilesetID"" "; + + var summation = FormatInvariant( + $@"SELECT ""A"".""FilesetID"" AS ""FilesetID"", COUNT(*) AS ""FileCount"", SUM(""C"".""Length"") AS ""FileSizes"" FROM ""FilesetEntry"" A, ""File"" B, ""Blockset"" C WHERE ""A"".""FileID"" = ""B"".""ID"" AND ""B"".""BlocksetID"" = ""C"".""ID"" AND ""A"".""FilesetID"" IN (SELECT DISTINCT ""FilesetID"" FROM ""{m_tablename}"") GROUP BY ""A"".""FilesetID"" "); using (var cmd = m_connection.CreateCommand()) - { - using (var rd = cmd.ExecuteReader( - $@"SELECT DISTINCT ""A"".""FilesetID"", ""A"".""IsFullBackup"", ""B"".""FileCount"", ""B"".""FileSizes"" FROM ""{m_tablename}"" A LEFT OUTER JOIN ( {summation} ) B ON ""A"".""FilesetID"" = ""B"".""FilesetID"" ORDER BY ""A"".""Timestamp"" DESC ") - ) + using (var rd = cmd.ExecuteReader(FormatInvariant( + $@"SELECT DISTINCT ""A"".""FilesetID"", ""A"".""IsFullBackup"", ""B"".""FileCount"", ""B"".""FileSizes"" FROM ""{m_tablename}"" A LEFT OUTER JOIN ( {summation} ) B ON ""A"".""FilesetID"" = ""B"".""FilesetID"" ORDER BY ""A"".""Timestamp"" DESC ")) + ) + while (rd.Read()) { - while (rd.Read()) - { - var id = rd.GetInt64(0); - var isFullBackup = rd.GetInt32(1); - var e = dict[id]; - var filecount = rd.ConvertValueToInt64(2, -1L); - var filesizes = rd.ConvertValueToInt64(3, -1L); + var id = rd.GetInt64(0); + var isFullBackup = rd.GetInt32(1); + var e = dict[id]; + var filecount = rd.ConvertValueToInt64(2, -1L); + var filesizes = rd.ConvertValueToInt64(3, -1L); - yield return new Fileset(e, isFullBackup, m_filesets[e].Value, filecount, filesizes); - } + yield return new Fileset(e, isFullBackup, m_filesets[e].Value, filecount, filesizes); } - } } } - + public void Dispose() { if (m_tablename != null) { - try + try { using (var cmd = m_connection.CreateCommand()) - { - cmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"" ", m_tablename)); - } + cmd.ExecuteNonQuery(FormatInvariant(@$"DROP TABLE IF EXISTS ""{m_tablename}"" ")); } - catch {} + catch { } finally { m_tablename = null; } } - + } } - + public IFileSets SelectFileSets(DateTime time, long[] versions) { return new FileSets(this, time, versions); } - + } } diff --git a/Duplicati/Library/Main/Database/LocalPurgeDatabase.cs b/Duplicati/Library/Main/Database/LocalPurgeDatabase.cs index 692f896c3..c539164f8 100644 --- a/Duplicati/Library/Main/Database/LocalPurgeDatabase.cs +++ b/Duplicati/Library/Main/Database/LocalPurgeDatabase.cs @@ -1,25 +1,26 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; +using System.Data; using System.Collections.Generic; using Duplicati.Library.Utility; @@ -38,22 +39,22 @@ namespace Duplicati.Library.Main.Database { } - public ITemporaryFileset CreateTemporaryFileset(long parentid, System.Data.IDbTransaction transaction) + public ITemporaryFileset CreateTemporaryFileset(long parentid, IDbTransaction transaction) { return new TemporaryFileset(parentid, this, m_connection, transaction); } - public string GetRemoteVolumeNameForFileset(long id, System.Data.IDbTransaction transaction) + public string GetRemoteVolumeNameForFileset(long id, IDbTransaction transaction) { using (var cmd = m_connection.CreateCommand(transaction)) using (var rd = cmd.ExecuteReader(@"SELECT ""B"".""Name"" FROM ""Fileset"" A, ""RemoteVolume"" B WHERE ""A"".""VolumeID"" = ""B"".""ID"" AND ""A"".""ID"" = ? ", id)) if (!rd.Read()) - throw new Exception(string.Format("No remote volume found for fileset with id {0}", id)); + throw new Exception($"No remote volume found for fileset with id {id}"); else return rd.ConvertValueToString(0); } - internal long CountOrphanFiles(System.Data.IDbTransaction transaction) + internal long CountOrphanFiles(IDbTransaction transaction) { using (var cmd = m_connection.CreateCommand(transaction)) using (var rd = cmd.ExecuteReader(@"SELECT COUNT(*) FROM ""FileLookup"" WHERE ""ID"" NOT IN (SELECT DISTINCT ""FileID"" FROM ""FilesetEntry"")")) @@ -70,15 +71,15 @@ namespace Duplicati.Library.Main.Database long RemovedFileSize { get; } void ApplyFilter(Library.Utility.IFilter filter); - void ApplyFilter(Action filtercommand); + void ApplyFilter(Action filtercommand); Tuple ConvertToPermanentFileset(string name, DateTime timestamp, bool isFullBackup); IEnumerable> ListAllDeletedFiles(); } private class TemporaryFileset : ITemporaryFileset { - private readonly System.Data.IDbConnection m_connection; - private readonly System.Data.IDbTransaction m_transaction; + private readonly IDbConnection m_connection; + private readonly IDbTransaction m_transaction; private readonly string m_tablename; private readonly LocalPurgeDatabase m_parentdb; @@ -86,23 +87,23 @@ namespace Duplicati.Library.Main.Database public long RemovedFileCount { get; private set; } public long RemovedFileSize { get; private set; } - public TemporaryFileset(long parentid, LocalPurgeDatabase parentdb, System.Data.IDbConnection connection, System.Data.IDbTransaction transaction) + public TemporaryFileset(long parentid, LocalPurgeDatabase parentdb, IDbConnection connection, IDbTransaction transaction) { - this.ParentID = parentid; + ParentID = parentid; m_parentdb = parentdb; m_connection = connection; m_transaction = transaction; m_tablename = "TempDeletedFilesTable-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); using (var cmd = m_connection.CreateCommand(m_transaction)) - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" (""FileID"" INTEGER PRIMARY KEY) ", m_tablename)); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{m_tablename}"" (""FileID"" INTEGER PRIMARY KEY) ")); } - public void ApplyFilter(Action filtercommand) + public void ApplyFilter(Action filtercommand) { using (var cmd = m_connection.CreateCommand(m_transaction)) filtercommand(cmd, ParentID, m_tablename); - + PostFilterChecks(); } @@ -117,8 +118,8 @@ namespace Duplicati.Library.Main.Database var filenamestable = "Filenames-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); using (var cmd = m_connection.CreateCommand(m_transaction)) { - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" (""Path"" TEXT NOT NULL) ", filenamestable)); - cmd.CommandText = string.Format(@"INSERT INTO ""{0}"" (""Path"") VALUES (?)", filenamestable); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{filenamestable}"" (""Path"" TEXT NOT NULL) ")); + cmd.CommandText = FormatInvariant($@"INSERT INTO ""{filenamestable}"" (""Path"") VALUES (?)"); cmd.AddParameter(); foreach (var s in p) @@ -127,18 +128,18 @@ namespace Duplicati.Library.Main.Database cmd.ExecuteNonQuery(); } - cmd.ExecuteNonQuery(string.Format(@"INSERT INTO ""{0}"" (""FileID"") SELECT DISTINCT ""A"".""FileID"" FROM ""FilesetEntry"" A, ""File"" B WHERE ""A"".""FilesetID"" = ? AND ""A"".""FileID"" = ""B"".""ID"" AND ""B"".""Path"" IN ""{1}""", m_tablename, filenamestable), ParentID); - cmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"" ", filenamestable)); + cmd.ExecuteNonQuery(FormatInvariant($@"INSERT INTO ""{m_tablename}"" (""FileID"") SELECT DISTINCT ""A"".""FileID"" FROM ""FilesetEntry"" A, ""File"" B WHERE ""A"".""FilesetID"" = ? AND ""A"".""FileID"" = ""B"".""ID"" AND ""B"".""Path"" IN ""{filenamestable}"""), ParentID); + cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{filenamestable}"" ")); } } else { // Do row-wise iteration - object[] values = new object[2]; + var values = new object[2]; using (var cmd = m_connection.CreateCommand(m_transaction)) using (var cmd2 = m_connection.CreateCommand(m_transaction)) { - cmd2.CommandText = string.Format(@"INSERT INTO ""{0}"" (""FileID"") VALUES (?)", m_tablename); + cmd2.CommandText = FormatInvariant($@"INSERT INTO ""{m_tablename}"" (""FileID"") VALUES (?)"); cmd2.AddParameters(1); using (var rd = cmd.ExecuteReader(@"SELECT ""B"".""Path"", ""A"".""FileID"" FROM ""FilesetEntry"" A, ""File"" B WHERE ""A"".""FilesetID"" = ? AND ""A"".""FileID"" = ""B"".""ID"" ", ParentID)) @@ -161,11 +162,11 @@ namespace Duplicati.Library.Main.Database { using (var cmd = m_connection.CreateCommand(m_transaction)) { - RemovedFileCount = cmd.ExecuteScalarInt64(string.Format(@"SELECT COUNT(*) FROM ""{0}""", m_tablename), 0); - RemovedFileSize = cmd.ExecuteScalarInt64(string.Format(@"SELECT SUM(""C"".""Length"") FROM ""{0}"" A, ""FileLookup"" B, ""Blockset"" C WHERE ""A"".""FileID"" = ""B"".""ID"" AND ""B"".""BlocksetID"" = ""C"".""ID"" ", m_tablename), 0); - var filesetcount = cmd.ExecuteScalarInt64(string.Format(@"SELECT COUNT(*) FROM ""FilesetEntry"" WHERE ""FilesetID"" = " + ParentID), 0); + RemovedFileCount = cmd.ExecuteScalarInt64(FormatInvariant($@"SELECT COUNT(*) FROM ""{m_tablename}"""), 0); + RemovedFileSize = cmd.ExecuteScalarInt64(FormatInvariant($@"SELECT SUM(""C"".""Length"") FROM ""{m_tablename}"" A, ""FileLookup"" B, ""Blockset"" C WHERE ""A"".""FileID"" = ""B"".""ID"" AND ""B"".""BlocksetID"" = ""C"".""ID"" "), 0); + var filesetcount = cmd.ExecuteScalarInt64(FormatInvariant($@"SELECT COUNT(*) FROM ""FilesetEntry"" WHERE ""FilesetID"" = {ParentID}"), 0); if (filesetcount == RemovedFileCount) - throw new Duplicati.Library.Interface.UserInformationException(string.Format("Refusing to purge {0} files from fileset with ID {1}, as that would remove the entire fileset.\nTo delete a fileset, use the \"delete\" command.", RemovedFileCount, ParentID), "PurgeWouldRemoveEntireFileset"); + throw new Interface.UserInformationException($"Refusing to purge {RemovedFileCount} files from fileset with ID {ParentID}, as that would remove the entire fileset.\nTo delete a fileset, use the \"delete\" command.", "PurgeWouldRemoveEntireFileset"); } } @@ -176,15 +177,15 @@ namespace Duplicati.Library.Main.Database m_parentdb.UpdateFullBackupStateInFileset(filesetid, isFullBackup); using (var cmd = m_connection.CreateCommand(m_transaction)) - cmd.ExecuteNonQuery(string.Format(@"INSERT INTO ""FilesetEntry"" (""FilesetID"", ""FileID"", ""Lastmodified"") SELECT ?, ""FileID"", ""LastModified"" FROM ""FilesetEntry"" WHERE ""FilesetID"" = ? AND ""FileID"" NOT IN ""{0}"" ", m_tablename), filesetid, ParentID); + cmd.ExecuteNonQuery(FormatInvariant($@"INSERT INTO ""FilesetEntry"" (""FilesetID"", ""FileID"", ""Lastmodified"") SELECT ?, ""FileID"", ""LastModified"" FROM ""FilesetEntry"" WHERE ""FilesetID"" = ? AND ""FileID"" NOT IN ""{m_tablename}"" "), filesetid, ParentID); return new Tuple(remotevolid, filesetid); } - + public IEnumerable> ListAllDeletedFiles() { using (var cmd = m_connection.CreateCommand(m_transaction)) - using (var rd = cmd.ExecuteReader(string.Format(@"SELECT ""B"".""Path"", ""C"".""Length"" FROM ""{0}"" A, ""File"" B, ""Blockset"" C WHERE ""A"".""FileID"" = ""B"".""ID"" AND ""B"".""BlocksetID"" = ""C"".""ID"" ", m_tablename))) + using (var rd = cmd.ExecuteReader(FormatInvariant($@"SELECT ""B"".""Path"", ""C"".""Length"" FROM ""{m_tablename}"" A, ""File"" B, ""Blockset"" C WHERE ""A"".""FileID"" = ""B"".""ID"" AND ""B"".""BlocksetID"" = ""C"".""ID"" "))) while (rd.Read()) yield return new KeyValuePair(rd.ConvertValueToString(0), rd.ConvertValueToInt64(1)); } @@ -194,7 +195,7 @@ namespace Duplicati.Library.Main.Database try { using (var cmd = m_connection.CreateCommand(m_transaction)) - cmd.ExecuteNonQuery(@"DROP TABLE IF EXISTS ""{0}""", m_tablename); + cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{m_tablename}""")); } catch { @@ -202,5 +203,5 @@ namespace Duplicati.Library.Main.Database } } - } + } } diff --git a/Duplicati/Library/Main/Database/LocalRecreateDatabase.cs b/Duplicati/Library/Main/Database/LocalRecreateDatabase.cs index ccc4f2939..05499fedf 100644 --- a/Duplicati/Library/Main/Database/LocalRecreateDatabase.cs +++ b/Duplicati/Library/Main/Database/LocalRecreateDatabase.cs @@ -19,10 +19,8 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; +using System.Data; using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; namespace Duplicati.Library.Main.Database { @@ -48,21 +46,21 @@ namespace Duplicati.Library.Main.Database } } - private readonly System.Data.IDbCommand m_insertFileCommand; - private readonly System.Data.IDbCommand m_insertFilesetEntryCommand; - private readonly System.Data.IDbCommand m_insertMetadatasetCommand; - private readonly System.Data.IDbCommand m_insertBlocksetCommand; - private readonly System.Data.IDbCommand m_insertBlocklistHashCommand; - private readonly System.Data.IDbCommand m_updateBlockVolumeCommand; - private readonly System.Data.IDbCommand m_insertTempBlockListHash; - private readonly System.Data.IDbCommand m_insertSmallBlockset; - private readonly System.Data.IDbCommand m_findBlocksetCommand; - private readonly System.Data.IDbCommand m_findMetadatasetCommand; - private readonly System.Data.IDbCommand m_findFilesetCommand; - private readonly System.Data.IDbCommand m_findTempBlockListHashCommand; - private readonly System.Data.IDbCommand m_findHashBlockCommand; - private readonly System.Data.IDbCommand m_insertBlockCommand; - private readonly System.Data.IDbCommand m_insertDuplicateBlockCommand; + private readonly IDbCommand m_insertFileCommand; + private readonly IDbCommand m_insertFilesetEntryCommand; + private readonly IDbCommand m_insertMetadatasetCommand; + private readonly IDbCommand m_insertBlocksetCommand; + private readonly IDbCommand m_insertBlocklistHashCommand; + private readonly IDbCommand m_updateBlockVolumeCommand; + private readonly IDbCommand m_insertTempBlockListHash; + private readonly IDbCommand m_insertSmallBlockset; + private readonly IDbCommand m_findBlocksetCommand; + private readonly IDbCommand m_findMetadatasetCommand; + private readonly IDbCommand m_findFilesetCommand; + private readonly IDbCommand m_findTempBlockListHashCommand; + private readonly IDbCommand m_findHashBlockCommand; + private readonly IDbCommand m_insertBlockCommand; + private readonly IDbCommand m_insertDuplicateBlockCommand; private string m_tempblocklist; private string m_tempsmalllist; @@ -78,13 +76,13 @@ namespace Duplicati.Library.Main.Database // {1} --> BlockHash-Size // {2} --> Temp-Table // {3} --> FullBlocklist-BlockCount [equals ({0} / {1}), if SQLite pays respect to ints] - private const string SELECT_BLOCKLIST_ENTRIES = - @" + private static string SELECT_BLOCKLIST_ENTRIES(long blocksize, long blockhashsize, string temptable, long fullBlockListBlockCount) => + FormatInvariant($@" SELECT ""E"".""BlocksetID"", - ""F"".""Index"" + (""E"".""BlocklistIndex"" * {3}) AS ""FullIndex"", + ""F"".""Index"" + (""E"".""BlocklistIndex"" * {fullBlockListBlockCount}) AS ""FullIndex"", ""F"".""BlockHash"", - MIN({0}, ""E"".""Length"" - ((""F"".""Index"" + (""E"".""BlocklistIndex"" * {3})) * {0})) AS ""BlockSize"", + MIN({blocksize}, ""E"".""Length"" - ((""F"".""Index"" + (""E"".""BlocklistIndex"" * {fullBlockListBlockCount})) * {blocksize})) AS ""BlockSize"", ""E"".""Hash"", ""E"".""BlocklistSize"", ""E"".""BlocklistHash"" @@ -95,7 +93,7 @@ namespace Duplicati.Library.Main.Database SELECT ""A"".""BlocksetID"", ""A"".""Index"" AS ""BlocklistIndex"", - MIN({3} * {1}, (((""B"".""Length"" + {0} - 1) / {0}) - (""A"".""Index"" * ({3}))) * {1}) AS ""BlocklistSize"", + MIN({fullBlockListBlockCount} * {blockhashsize}, (((""B"".""Length"" + {blocksize} - 1) / {blocksize}) - (""A"".""Index"" * ({fullBlockListBlockCount}))) * {blockhashsize}) AS ""BlocklistSize"", ""A"".""Hash"" AS ""BlocklistHash"", ""B"".""Length"" FROM @@ -110,13 +108,13 @@ namespace Duplicati.Library.Main.Database AND ""C"".""BlocklistSize"" = ""D"".""Size"" ) E, - ""{2}"" F + ""{temptable}"" F WHERE ""F"".""BlocklistHash"" = ""E"".""Hash"" ORDER BY ""E"".""BlocksetID"", ""FullIndex"" -"; +"); public LocalRecreateDatabase(LocalDatabase parentdb, Options options) : base(parentdb) @@ -126,105 +124,47 @@ namespace Duplicati.Library.Main.Database using (var cmd = m_connection.CreateCommand()) { - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" (""BlockListHash"" TEXT NOT NULL, ""BlockHash"" TEXT NOT NULL, ""Index"" INTEGER NOT NULL)", m_tempblocklist)); - cmd.ExecuteNonQuery(string.Format(@"CREATE INDEX ""Index_{0}"" ON ""{0}"" (""BlockListHash"");", m_tempblocklist)); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{m_tempblocklist}"" (""BlockListHash"" TEXT NOT NULL, ""BlockHash"" TEXT NOT NULL, ""Index"" INTEGER NOT NULL)")); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE INDEX ""Index_{m_tempblocklist}"" ON ""{m_tempblocklist}"" (""BlockListHash"");")); - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" (""FileHash"" TEXT NOT NULL, ""BlockHash"" TEXT NOT NULL, ""BlockSize"" INTEGER NOT NULL)", m_tempsmalllist)); - cmd.ExecuteNonQuery(string.Format(@"CREATE UNIQUE INDEX ""Index_File_{0}"" ON ""{0}"" (""FileHash"", ""BlockSize"");", m_tempsmalllist)); - cmd.ExecuteNonQuery(string.Format(@"CREATE UNIQUE INDEX ""Index_Block_{0}"" ON ""{0}"" (""BlockHash"", ""BlockSize"");", m_tempsmalllist)); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{m_tempsmalllist}"" (""FileHash"" TEXT NOT NULL, ""BlockHash"" TEXT NOT NULL, ""BlockSize"" INTEGER NOT NULL)")); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE UNIQUE INDEX ""Index_File_{m_tempsmalllist}"" ON ""{m_tempsmalllist}"" (""FileHash"", ""BlockSize"");")); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE UNIQUE INDEX ""Index_Block_{m_tempsmalllist}"" ON ""{m_tempsmalllist}"" (""BlockHash"", ""BlockSize"");")); } - m_insertFileCommand = m_connection.CreateCommand(); - m_insertFilesetEntryCommand = m_connection.CreateCommand(); - m_insertMetadatasetCommand = m_connection.CreateCommand(); - m_insertBlocksetCommand = m_connection.CreateCommand(); - m_insertBlocklistHashCommand = m_connection.CreateCommand(); - m_updateBlockVolumeCommand = m_connection.CreateCommand(); - m_insertTempBlockListHash = m_connection.CreateCommand(); - m_insertSmallBlockset = m_connection.CreateCommand(); - m_findBlocksetCommand = m_connection.CreateCommand(); - m_findMetadatasetCommand = m_connection.CreateCommand(); - m_findFilesetCommand = m_connection.CreateCommand(); - m_findTempBlockListHashCommand = m_connection.CreateCommand(); - m_findHashBlockCommand = m_connection.CreateCommand(); - m_insertBlockCommand = m_connection.CreateCommand(); - m_insertDuplicateBlockCommand = m_connection.CreateCommand(); - - m_insertFileCommand.CommandText = @"INSERT INTO ""FileLookup"" (""PrefixID"", ""Path"", ""BlocksetID"", ""MetadataID"") VALUES (?,?,?,?); SELECT last_insert_rowid();"; - m_insertFileCommand.AddParameters(4); - - m_insertFilesetEntryCommand.CommandText = @"INSERT INTO ""FilesetEntry"" (""FilesetID"", ""FileID"", ""Lastmodified"") VALUES (?,?,?)"; - m_insertFilesetEntryCommand.AddParameters(3); - - m_insertMetadatasetCommand.CommandText = @"INSERT INTO ""Metadataset"" (""BlocksetID"") VALUES (?); SELECT last_insert_rowid();"; - m_insertMetadatasetCommand.AddParameters(1); - - m_insertBlocksetCommand.CommandText = @"INSERT INTO ""Blockset"" (""Length"", ""FullHash"") VALUES (?,?); SELECT last_insert_rowid();"; - m_insertBlocksetCommand.AddParameters(2); - - m_insertBlocklistHashCommand.CommandText = @"INSERT INTO ""BlocklistHash"" (""BlocksetID"", ""Index"", ""Hash"") VALUES (?,?,?)"; - m_insertBlocklistHashCommand.AddParameters(3); - - m_updateBlockVolumeCommand.CommandText = @"UPDATE ""Block"" SET ""VolumeID"" = ? WHERE ""Hash"" = ? AND ""Size"" = ?"; - m_updateBlockVolumeCommand.AddParameters(3); - - m_insertTempBlockListHash.CommandText = string.Format(@"INSERT INTO ""{0}"" (""BlocklistHash"", ""BlockHash"", ""Index"") VALUES (?,?,?) ", m_tempblocklist); - m_insertTempBlockListHash.AddParameters(3); - - m_insertSmallBlockset.CommandText = string.Format(@"INSERT OR IGNORE INTO ""{0}"" (""FileHash"", ""BlockHash"", ""BlockSize"") VALUES (?,?,?) ", m_tempsmalllist); - m_insertSmallBlockset.AddParameters(3); - - m_findBlocksetCommand.CommandText = @"SELECT ""ID"" FROM ""Blockset"" WHERE ""Length"" = ? AND ""FullHash"" = ? "; - m_findBlocksetCommand.AddParameters(2); - - m_findMetadatasetCommand.CommandText = @"SELECT ""Metadataset"".""ID"" FROM ""Metadataset"",""Blockset"" WHERE ""Metadataset"".""BlocksetID"" = ""Blockset"".""ID"" AND ""Blockset"".""FullHash"" = ? AND ""Blockset"".""Length"" = ? "; - m_findMetadatasetCommand.AddParameters(2); - - m_findFilesetCommand.CommandText = @"SELECT ""ID"" FROM ""FileLookup"" WHERE ""PrefixID"" = ? AND ""Path"" = ? AND ""BlocksetID"" = ? AND ""MetadataID"" = ? "; - m_findFilesetCommand.AddParameters(4); - - m_findTempBlockListHashCommand.CommandText = string.Format(@"SELECT DISTINCT ""BlockListHash"" FROM ""{0}"" WHERE ""BlockListHash"" = ? ", m_tempblocklist); - m_findTempBlockListHashCommand.AddParameters(1); - - m_findHashBlockCommand.CommandText = @"SELECT ""VolumeID"" FROM ""Block"" WHERE ""Hash"" = ? AND ""Size"" = ? "; - m_findHashBlockCommand.AddParameters(2); - - m_insertBlockCommand.CommandText = @"INSERT INTO ""Block"" (""Hash"", ""Size"", ""VolumeID"") VALUES (?,?,?)"; - m_insertBlockCommand.AddParameters(3); - - m_insertDuplicateBlockCommand.CommandText = @"INSERT OR IGNORE INTO ""DuplicateBlock"" (""BlockID"", ""VolumeID"") VALUES ((SELECT ""ID"" FROM ""Block"" WHERE ""Hash"" = ? AND ""Size"" = ?), ?)"; - m_insertDuplicateBlockCommand.AddParameters(3); + m_insertFileCommand = m_connection.CreateCommand(@"INSERT INTO ""FileLookup"" (""PrefixID"", ""Path"", ""BlocksetID"", ""MetadataID"") VALUES (?,?,?,?); SELECT last_insert_rowid();"); + m_insertFilesetEntryCommand = m_connection.CreateCommand(@"INSERT INTO ""FilesetEntry"" (""FilesetID"", ""FileID"", ""Lastmodified"") VALUES (?,?,?)"); + m_insertMetadatasetCommand = m_connection.CreateCommand(@"INSERT INTO ""Metadataset"" (""BlocksetID"") VALUES (?); SELECT last_insert_rowid();"); + m_insertBlocksetCommand = m_connection.CreateCommand(@"INSERT INTO ""Blockset"" (""Length"", ""FullHash"") VALUES (?,?); SELECT last_insert_rowid();"); + m_insertBlocklistHashCommand = m_connection.CreateCommand(@"INSERT INTO ""BlocklistHash"" (""BlocksetID"", ""Index"", ""Hash"") VALUES (?,?,?)"); + m_updateBlockVolumeCommand = m_connection.CreateCommand(@"UPDATE ""Block"" SET ""VolumeID"" = ? WHERE ""Hash"" = ? AND ""Size"" = ?"); + m_insertTempBlockListHash = m_connection.CreateCommand(FormatInvariant($@"INSERT INTO ""{m_tempblocklist}"" (""BlocklistHash"", ""BlockHash"", ""Index"") VALUES (?,?,?) ")); + m_insertSmallBlockset = m_connection.CreateCommand(FormatInvariant($@"INSERT OR IGNORE INTO ""{m_tempsmalllist}"" (""FileHash"", ""BlockHash"", ""BlockSize"") VALUES (?,?,?) ")); + m_findBlocksetCommand = m_connection.CreateCommand(@"SELECT ""ID"" FROM ""Blockset"" WHERE ""Length"" = ? AND ""FullHash"" = ? "); + m_findMetadatasetCommand = m_connection.CreateCommand(@"SELECT ""Metadataset"".""ID"" FROM ""Metadataset"",""Blockset"" WHERE ""Metadataset"".""BlocksetID"" = ""Blockset"".""ID"" AND ""Blockset"".""FullHash"" = ? AND ""Blockset"".""Length"" = ? "); + m_findFilesetCommand = m_connection.CreateCommand(@"SELECT ""ID"" FROM ""FileLookup"" WHERE ""PrefixID"" = ? AND ""Path"" = ? AND ""BlocksetID"" = ? AND ""MetadataID"" = ? "); + m_findTempBlockListHashCommand = m_connection.CreateCommand(FormatInvariant($@"SELECT DISTINCT ""BlockListHash"" FROM ""{m_tempblocklist}"" WHERE ""BlockListHash"" = ? ")); + m_findHashBlockCommand = m_connection.CreateCommand(@"SELECT ""VolumeID"" FROM ""Block"" WHERE ""Hash"" = ? AND ""Size"" = ? "); + m_insertBlockCommand = m_connection.CreateCommand(@"INSERT INTO ""Block"" (""Hash"", ""Size"", ""VolumeID"") VALUES (?,?,?)"); + m_insertDuplicateBlockCommand = m_connection.CreateCommand(@"INSERT OR IGNORE INTO ""DuplicateBlock"" (""BlockID"", ""VolumeID"") VALUES ((SELECT ""ID"" FROM ""Block"" WHERE ""Hash"" = ? AND ""Size"" = ?), ?)"); } - public void FindMissingBlocklistHashes(long hashsize, long blocksize, System.Data.IDbTransaction transaction) + public void FindMissingBlocklistHashes(long hashsize, long blocksize, IDbTransaction transaction) { - using (var cmd = m_connection.CreateCommand()) + using (var cmd = m_connection.CreateCommand(transaction)) { - cmd.Transaction = transaction; - //Update all small blocklists and matching blocks - var selectSmallBlocks = string.Format(@"SELECT ""BlockHash"", ""BlockSize"" FROM ""{0}""", m_tempsmalllist); + var selectSmallBlocks = FormatInvariant($@"SELECT ""BlockHash"", ""BlockSize"" FROM ""{m_tempsmalllist}"""); - var selectBlockHashes = string.Format( - @"SELECT ""BlockHash"" AS ""FullHash"", ""BlockSize"" AS ""Length"" FROM ( " + - SELECT_BLOCKLIST_ENTRIES + - @" )", - blocksize, - hashsize, - m_tempblocklist, - blocksize / hashsize - ); + var selectBlockHashes = FormatInvariant($@"SELECT ""BlockHash"" AS ""FullHash"", ""BlockSize"" AS ""Length"" FROM ( {SELECT_BLOCKLIST_ENTRIES(blocksize, hashsize, m_tempblocklist, blocksize / hashsize)} )"); var selectAllBlocks = @"SELECT DISTINCT ""FullHash"", ""Length"" FROM (" + selectBlockHashes + " UNION " + selectSmallBlocks + " )"; - var selectNewBlocks = string.Format( - @"SELECT ""FullHash"" AS ""Hash"", ""Length"" AS ""Size"", -1 AS ""VolumeID"" " + - @" FROM (SELECT ""A"".""FullHash"", ""A"".""Length"", CASE WHEN ""B"".""Hash"" IS NULL THEN '' ELSE ""B"".""Hash"" END AS ""Hash"", CASE WHEN ""B"".""Size"" is NULL THEN -1 ELSE ""B"".""Size"" END AS ""Size"" FROM ({0}) A" + - @" LEFT OUTER JOIN ""Block"" B ON ""B"".""Hash"" = ""A"".""FullHash"" AND ""B"".""Size"" = ""A"".""Length"" )" + - @" WHERE ""FullHash"" != ""Hash"" AND ""Length"" != ""Size"" ", - selectAllBlocks - ); + var selectNewBlocks = FormatInvariant($@"SELECT ""FullHash"" AS ""Hash"", ""Length"" AS ""Size"", -1 AS ""VolumeID"" +FROM (SELECT ""A"".""FullHash"", ""A"".""Length"", CASE WHEN ""B"".""Hash"" IS NULL THEN '' ELSE ""B"".""Hash"" END AS ""Hash"", CASE WHEN ""B"".""Size"" is NULL THEN -1 ELSE ""B"".""Size"" END AS ""Size"" FROM ({selectAllBlocks}) A +LEFT OUTER JOIN ""Block"" B ON ""B"".""Hash"" = ""A"".""FullHash"" AND ""B"".""Size"" = ""A"".""Length"" ) +WHERE ""FullHash"" != ""Hash"" AND ""Length"" != ""Size"" "); var insertBlocksCommand = @"INSERT INTO ""Block"" (""Hash"", ""Size"", ""VolumeID"") " + @@ -233,26 +173,14 @@ namespace Duplicati.Library.Main.Database // Insert all known blocks into block table with volumeid = -1 cmd.ExecuteNonQuery(insertBlocksCommand); - var selectBlocklistBlocksetEntries = string.Format( - @"SELECT ""E"".""BlocksetID"" AS ""BlocksetID"", ""D"".""FullIndex"" AS ""Index"", ""F"".""ID"" AS ""BlockID"" FROM ( " + - SELECT_BLOCKLIST_ENTRIES + - @") D, ""BlocklistHash"" E, ""Block"" F, ""Block"" G WHERE ""D"".""BlocksetID"" = ""E"".""BlocksetID"" AND ""D"".""BlocklistHash"" = ""E"".""Hash"" AND ""D"".""BlocklistSize"" = ""G"".""Size"" AND ""D"".""BlocklistHash"" = ""G"".""Hash"" AND ""D"".""Blockhash"" = ""F"".""Hash"" AND ""D"".""BlockSize"" = ""F"".""Size"" ", - + var selectBlocklistBlocksetEntries = FormatInvariant( // TODO: The BlocklistHash join seems to be unnecessary, but the join might be required to work around a from really old versions of Duplicati // this could be used instead //@") D, ""Block"" WHERE ""BlockQuery"".""BlockHash"" = ""Block"".""Hash"" AND ""BlockQuery"".""BlockSize"" = ""Block"".""Size"" "; + $@"SELECT ""E"".""BlocksetID"" AS ""BlocksetID"", ""D"".""FullIndex"" AS ""Index"", ""F"".""ID"" AS ""BlockID"" FROM ( {SELECT_BLOCKLIST_ENTRIES(blocksize, hashsize, m_tempblocklist, blocksize / hashsize)}) D, ""BlocklistHash"" E, ""Block"" F, ""Block"" G WHERE ""D"".""BlocksetID"" = ""E"".""BlocksetID"" AND ""D"".""BlocklistHash"" = ""E"".""Hash"" AND ""D"".""BlocklistSize"" = ""G"".""Size"" AND ""D"".""BlocklistHash"" = ""G"".""Hash"" AND ""D"".""Blockhash"" = ""F"".""Hash"" AND ""D"".""BlockSize"" = ""F"".""Size"" " + ); - blocksize, - hashsize, - m_tempblocklist, - blocksize / hashsize - ); - - var selectBlocksetEntries = string.Format( - @"SELECT ""Blockset"".""ID"" AS ""BlocksetID"", 0 AS ""Index"", ""Block"".""ID"" AS ""BlockID"" FROM ""Blockset"", ""Block"", ""{1}"" S WHERE ""Blockset"".""Fullhash"" = ""S"".""FileHash"" AND ""S"".""BlockHash"" = ""Block"".""Hash"" AND ""S"".""BlockSize"" = ""Block"".""Size"" AND ""Blockset"".""Length"" = ""S"".""BlockSize"" AND ""Blockset"".""Length"" <= {0} ", - blocksize, - m_tempsmalllist - ); + var selectBlocksetEntries = FormatInvariant($@"SELECT ""Blockset"".""ID"" AS ""BlocksetID"", 0 AS ""Index"", ""Block"".""ID"" AS ""BlockID"" FROM ""Blockset"", ""Block"", ""{m_tempsmalllist}"" S WHERE ""Blockset"".""Fullhash"" = ""S"".""FileHash"" AND ""S"".""BlockHash"" = ""Block"".""Hash"" AND ""S"".""BlockSize"" = ""Block"".""Size"" AND ""Blockset"".""Length"" = ""S"".""BlockSize"" AND ""Blockset"".""Length"" <= {blocksize} "); var selectAllBlocksetEntries = selectBlocklistBlocksetEntries + @@ -277,8 +205,8 @@ namespace Duplicati.Library.Main.Database using (var fixcmd = m_connection.CreateCommand()) { - fixcmd.ExecuteNonQuery(string.Format(@"CREATE TABLE ""{0}-Failure"" AS SELECT * FROM ""{0}"" ", m_tempblocklist)); - fixcmd.ExecuteNonQuery(string.Format(@"CREATE TABLE ""{0}-Failure"" AS SELECT * FROM ""{0}"" ", m_tempsmalllist)); + fixcmd.ExecuteNonQuery(FormatInvariant($@"CREATE TABLE ""{m_tempblocklist}-Failure"" AS SELECT * FROM ""{m_tempblocklist}"" ")); + fixcmd.ExecuteNonQuery(FormatInvariant($@"CREATE TABLE ""{m_tempsmalllist}-Failure"" AS SELECT * FROM ""{m_tempsmalllist}"" ")); } throw new Exception("The recreate failed, please create a bug-report from this database and send it to the developers for further analysis"); @@ -302,75 +230,55 @@ namespace Duplicati.Library.Main.Database /// temp small list structure: filehash, blockhash, blocksize: as the small files are defined /// by the fact that they are contained in a single block, blockhash is the same as the filehash, /// and blocksize can vary from 0 to the configured block size for the backup - public void AddBlockAndBlockSetEntryFromTemp(long hashsize, long blocksize, System.Data.IDbTransaction transaction, bool hashOnly = false) + public void AddBlockAndBlockSetEntryFromTemp(long hashsize, long blocksize, IDbTransaction transaction, bool hashOnly = false) { - using (var cmd = m_connection.CreateCommand()) + using (var cmd = m_connection.CreateCommand(transaction)) { - cmd.Transaction = transaction; - - var insertBlocksCommand = string.Format( - @"INSERT INTO BLOCK (Hash, Size, VolumeID) " + - @"SELECT DISTINCT BlockHash AS Hash, BlockSize AS Size, -1 AS VolumeID FROM " + - @"(" + - @"SELECT NB.BlockHash, " + - @"MIN({0}, BS.Length - ((NB.""Index"" + (BH.""Index"" * {1})) * {0})) AS BlockSize " + - @"FROM (" + - @" SELECT TBL.BlockListHash, TBL.BlockHash, TBL.""Index"" FROM {2} TBL " + - @" LEFT OUTER JOIN Block B ON (B.Hash = TBL.BlockHash) " + - @" WHERE B.Hash IS NULL " + - @" ) NB " + - @"JOIN BlocklistHash BH ON (BH.Hash = NB.BlocklistHash) " + - @"JOIN Blockset BS ON (BS.ID = BH.Blocksetid) " + - @"", - blocksize, - blocksize / hashsize, - m_tempblocklist - ); + var insertBlocksCommand = FormatInvariant($@"INSERT INTO BLOCK (Hash, Size, VolumeID) +SELECT DISTINCT BlockHash AS Hash, BlockSize AS Size, -1 AS VolumeID FROM +( +SELECT NB.BlockHash, +MIN({blocksize}, BS.Length - ((NB.""Index"" + (BH.""Index"" * {blocksize / hashsize})) * {blocksize})) AS BlockSize +FROM ( + SELECT TBL.BlockListHash, TBL.BlockHash, TBL.""Index"" FROM {m_tempblocklist} TBL + LEFT OUTER JOIN Block B ON (B.Hash = TBL.BlockHash) + WHERE B.Hash IS NULL + ) NB +JOIN BlocklistHash BH ON (BH.Hash = NB.BlocklistHash) +JOIN Blockset BS ON (BS.ID = BH.Blocksetid) "); if (!hashOnly) { - insertBlocksCommand += string.Format( - @" UNION " + - @"" + - @"SELECT TS.BlockHash, TS.BlockSize FROM " + - @"{0} TS " + - @"WHERE NOT EXISTS (SELECT ""X"" FROM Block AS B WHERE " + - @" B.Hash = TS.BlockHash AND " + - @" B.Size = TS.BlockSize) " + - @")", - m_tempsmalllist - ); + insertBlocksCommand += FormatInvariant($@" UNION +SELECT TS.BlockHash, TS.BlockSize FROM +{m_tempsmalllist} TS +WHERE NOT EXISTS (SELECT ""X"" FROM Block AS B WHERE + B.Hash = TS.BlockHash AND + B.Size = TS.BlockSize) +)"); } - var insertBlocksetEntriesCommand = string.Format( - @"INSERT INTO BlocksetEntry (BlocksetID, ""Index"", BlockID) " + - @"SELECT DISTINCT BH.blocksetid, (BH.""Index"" * {0})+TBL.""Index"" as FullIndex, BK.ID AS BlockID " + - @"FROM {1} TBL " + - @" JOIN blocklisthash BH ON (BH.hash = TBL.blocklisthash) " + - @" JOIN block BK ON (BK.Hash = TBL.BlockHash) " + - @" LEFT OUTER JOIN BlocksetEntry BE ON (BE.BlockSetID = BH.BlocksetID AND BE.""Index"" = (BH.""Index"" * {0})+TBL.""Index"") " + - @"WHERE " + - @"BE.BlockSetID IS NULL ", - blocksize / hashsize, - m_tempblocklist - ); + var insertBlocksetEntriesCommand = FormatInvariant($@"INSERT INTO BlocksetEntry (BlocksetID, ""Index"", BlockID) +SELECT DISTINCT BH.blocksetid, (BH.""Index"" * {blocksize / hashsize})+TBL.""Index"" as FullIndex, BK.ID AS BlockID +FROM {m_tempblocklist} TBL + JOIN blocklisthash BH ON (BH.hash = TBL.blocklisthash) + JOIN block BK ON (BK.Hash = TBL.BlockHash) + LEFT OUTER JOIN BlocksetEntry BE ON (BE.BlockSetID = BH.BlocksetID AND BE.""Index"" = (BH.""Index"" * {blocksize / hashsize})+TBL.""Index"") +WHERE +BE.BlockSetID IS NULL "); if (!hashOnly) { - insertBlocksetEntriesCommand += string.Format( - @" UNION " + - @"SELECT BS.ID AS BlocksetID, 0 AS ""Index"", BL.ID AS BlockID " + - @"FROM {1} TS " + - @" JOIN Blockset BS ON (BS.FullHash = TS.FileHash AND " + - @" BS.Length = TS.BlockSize AND " + - @" BS.Length <= {0}) " + - @" JOIN Block BL ON (BL.Hash = TS.BlockHash AND " + - @" BL.Size = TS.BlockSize) " + - @" LEFT OUTER JOIN BlocksetEntry BE ON (BE.BlocksetID = BS.ID AND BE.""Index"" = 0) " + - @"WHERE " + - @"BE.BlocksetID IS NULL ", - blocksize, - m_tempsmalllist - ); + insertBlocksetEntriesCommand += FormatInvariant($@" UNION +SELECT BS.ID AS BlocksetID, 0 AS ""Index"", BL.ID AS BlockID +FROM {m_tempsmalllist} TS + JOIN Blockset BS ON (BS.FullHash = TS.FileHash AND + BS.Length = TS.BlockSize AND + BS.Length <= {blocksize}) + JOIN Block BL ON (BL.Hash = TS.BlockHash AND + BL.Size = TS.BlockSize) + LEFT OUTER JOIN BlocksetEntry BE ON (BE.BlocksetID = BS.ID AND BE.""Index"" = 0) +WHERE +BE.BlocksetID IS NULL "); } try @@ -386,8 +294,8 @@ namespace Duplicati.Library.Main.Database using (var fixcmd = m_connection.CreateCommand()) { - fixcmd.ExecuteNonQuery(string.Format(@"CREATE TABLE ""{0}_Failure"" AS SELECT * FROM ""{0}"" ", m_tempblocklist)); - fixcmd.ExecuteNonQuery(string.Format(@"CREATE TABLE ""{0}_Failure"" AS SELECT * FROM ""{0}"" ", m_tempsmalllist)); + fixcmd.ExecuteNonQuery(FormatInvariant($@"CREATE TABLE ""{m_tempblocklist}_Failure"" AS SELECT * FROM ""{m_tempblocklist}"" ")); + fixcmd.ExecuteNonQuery(FormatInvariant($@"CREATE TABLE ""{m_tempsmalllist}_Failure"" AS SELECT * FROM ""{m_tempsmalllist}"" ")); } throw new Exception("The recreate failed, please create a bug-report from this database and send it to the developers for further analysis"); @@ -395,31 +303,29 @@ namespace Duplicati.Library.Main.Database } } - public void AddDirectoryEntry(long filesetid, long pathprefixid, string path, DateTime time, long metadataid, System.Data.IDbTransaction transaction) + public void AddDirectoryEntry(long filesetid, long pathprefixid, string path, DateTime time, long metadataid, IDbTransaction transaction) { AddEntry(filesetid, pathprefixid, path, time, FOLDER_BLOCKSET_ID, metadataid, transaction); } - public void AddSymlinkEntry(long filesetid, long pathprefixid, string path, DateTime time, long metadataid, System.Data.IDbTransaction transaction) + public void AddSymlinkEntry(long filesetid, long pathprefixid, string path, DateTime time, long metadataid, IDbTransaction transaction) { AddEntry(filesetid, pathprefixid, path, time, SYMLINK_BLOCKSET_ID, metadataid, transaction); } - public void AddFileEntry(long filesetid, long pathprefixid, string path, DateTime time, long blocksetid, long metadataid, System.Data.IDbTransaction transaction) + public void AddFileEntry(long filesetid, long pathprefixid, string path, DateTime time, long blocksetid, long metadataid, IDbTransaction transaction) { AddEntry(filesetid, pathprefixid, path, time, blocksetid, metadataid, transaction); } - private void AddEntry(long filesetid, long pathprefixid, string path, DateTime time, long blocksetid, long metadataid, System.Data.IDbTransaction transaction) + private void AddEntry(long filesetid, long pathprefixid, string path, DateTime time, long blocksetid, long metadataid, IDbTransaction transaction) { - var fileid = -1L; - m_findFilesetCommand.Transaction = transaction; m_findFilesetCommand.SetParameterValue(0, pathprefixid); m_findFilesetCommand.SetParameterValue(1, path); m_findFilesetCommand.SetParameterValue(2, blocksetid); m_findFilesetCommand.SetParameterValue(3, metadataid); - fileid = m_findFilesetCommand.ExecuteScalarInt64(-1); + var fileid = m_findFilesetCommand.ExecuteScalarInt64(-1); if (fileid < 0) { @@ -438,7 +344,7 @@ namespace Duplicati.Library.Main.Database m_insertFilesetEntryCommand.ExecuteNonQuery(); } - public long AddMetadataset(string metahash, long metahashsize, IEnumerable metablocklisthashes, long expectedmetablocklisthashes, System.Data.IDbTransaction transaction) + public long AddMetadataset(string metahash, long metahashsize, IEnumerable metablocklisthashes, long expectedmetablocklisthashes, IDbTransaction transaction) { var metadataid = -1L; if (metahash == null) @@ -460,7 +366,7 @@ namespace Duplicati.Library.Main.Database return metadataid; } - public long AddBlockset(string fullhash, long size, IEnumerable blocklisthashes, long expectedblocklisthashes, System.Data.IDbTransaction transaction) + public long AddBlockset(string fullhash, long size, IEnumerable blocklisthashes, long expectedblocklisthashes, IDbTransaction transaction) { m_findBlocksetCommand.Transaction = transaction; m_findBlocksetCommand.SetParameterValue(0, size); @@ -502,7 +408,7 @@ namespace Duplicati.Library.Main.Database return blocksetid; } - public bool UpdateBlock(string hash, long size, long volumeID, System.Data.IDbTransaction transaction, ref bool anyChange) + public bool UpdateBlock(string hash, long size, long volumeID, IDbTransaction transaction, ref bool anyChange) { m_findHashBlockCommand.Transaction = transaction; m_findHashBlockCommand.SetParameterValue(0, hash); @@ -534,7 +440,7 @@ namespace Duplicati.Library.Main.Database m_updateBlockVolumeCommand.SetParameterValue(2, size); var c = m_updateBlockVolumeCommand.ExecuteNonQuery(); if (c != 1) - throw new Exception(string.Format("Failed to update table, found {0} entries for key {1} with size {2}", c, hash, size)); + throw new Exception($"Failed to update table, found {c} entries for key {hash} with size {size}"); return true; } @@ -550,7 +456,7 @@ namespace Duplicati.Library.Main.Database } } - public void AddSmallBlocksetLink(string filehash, string blockhash, long blocksize, System.Data.IDbTransaction transaction) + public void AddSmallBlocksetLink(string filehash, string blockhash, long blocksize, IDbTransaction transaction) { m_insertSmallBlockset.Transaction = transaction; m_insertSmallBlockset.SetParameterValue(0, filehash); @@ -559,7 +465,7 @@ namespace Duplicati.Library.Main.Database m_insertSmallBlockset.ExecuteNonQuery(); } - public bool AddTempBlockListHash(string hash, IEnumerable blocklisthashes, System.Data.IDbTransaction transaction) + public bool AddTempBlockListHash(string hash, IEnumerable blocklisthashes, IDbTransaction transaction) { m_findTempBlockListHashCommand.Transaction = transaction; m_findTempBlockListHashCommand.SetParameterValue(0, hash); @@ -603,30 +509,22 @@ namespace Duplicati.Library.Main.Database var selectCommand = @"SELECT DISTINCT ""RemoteVolume"".""Name"", ""RemoteVolume"".""Hash"", ""RemoteVolume"".""Size"", ""RemoteVolume"".""ID"" FROM ""RemoteVolume"""; var missingBlocklistEntries = - string.Format( - @"SELECT ""BlocklistHash"".""Hash"" FROM ""BlocklistHash"" LEFT OUTER JOIN ""BlocksetEntry"" ON ""BlocksetEntry"".""Index"" = (""BlocklistHash"".""Index"" * {0}) AND ""BlocksetEntry"".""BlocksetID"" = ""BlocklistHash"".""BlocksetID"" WHERE ""BlocksetEntry"".""BlocksetID"" IS NULL", - blocksize / hashsize - ); + FormatInvariant($@"SELECT ""BlocklistHash"".""Hash"" FROM ""BlocklistHash"" LEFT OUTER JOIN ""BlocksetEntry"" ON ""BlocksetEntry"".""Index"" = (""BlocklistHash"".""Index"" * {blocksize / hashsize}) AND ""BlocksetEntry"".""BlocksetID"" = ""BlocklistHash"".""BlocksetID"" WHERE ""BlocksetEntry"".""BlocksetID"" IS NULL"); var missingBlockInfo = @"SELECT ""VolumeID"" FROM ""Block"" WHERE ""VolumeID"" < 0 AND SIZE > 0"; - var missingBlocklistVolumes = string.Format( - @"SELECT ""VolumeID"" FROM ""Block"", (" + - missingBlocklistEntries + - @") A WHERE ""A"".""Hash"" = ""Block"".""Hash"" " + var missingBlocklistVolumes = FormatInvariant( + $@"SELECT ""VolumeID"" FROM ""Block"", ({missingBlocklistEntries}) A WHERE ""A"".""Hash"" = ""Block"".""Hash"" " ); - var countMissingInformation = string.Format( - @"SELECT COUNT(*) FROM (SELECT DISTINCT ""VolumeID"" FROM ({0} UNION {1}))", - missingBlockInfo, - missingBlocklistVolumes); + var countMissingInformation = FormatInvariant($@"SELECT COUNT(*) FROM (SELECT DISTINCT ""VolumeID"" FROM ({missingBlockInfo} UNION {missingBlocklistVolumes}))"); if (passNo == 0) { // On the first pass, we select all the volumes we know we need, // which may be an empty list - cmd.CommandText = string.Format(selectCommand + @" WHERE ""ID"" IN ({0})", missingBlocklistVolumes); + cmd.CommandText = selectCommand + FormatInvariant($@" WHERE ""ID"" IN ({missingBlocklistVolumes})"); // Reset the list m_proccessedVolumes = new Dictionary(); @@ -645,7 +543,7 @@ namespace Duplicati.Library.Main.Database var mentionedVolumes = @"SELECT DISTINCT ""VolumeID"" FROM ""Block"" "; - cmd.CommandText = string.Format(selectCommand + @" WHERE ""ID"" NOT IN ({0}) AND ""Type"" = ? ", mentionedVolumes); + cmd.CommandText = selectCommand + FormatInvariant($@" WHERE ""ID"" NOT IN ({mentionedVolumes}) AND ""Type"" = ? "); cmd.AddParameter(RemoteVolumeType.Blocks.ToString()); } else @@ -661,7 +559,6 @@ namespace Duplicati.Library.Main.Database { while (rd.Read()) { - var volumeID = rd.GetInt64(3); // Guard against multiple downloads of the same file @@ -677,8 +574,6 @@ namespace Duplicati.Library.Main.Database } } } - - } } @@ -692,7 +587,7 @@ namespace Duplicati.Library.Main.Database // existing ones (as recorded in the DuplicateBlock table) // The second part removes references to the non-present remote files, // and marks the index files that pointed to them, such that they will be removed later on - var sql = $@" + var sql = FormatInvariant($@" CREATE TEMPORARY TABLE ""{tablename}"" AS SELECT ""A"".""ID"" AS ""BlockID"", ""A"".""VolumeID"" AS ""SourceVolumeID"", ""A"".""State"" AS ""SourceVolumeState"", ""B"".""VolumeID"" AS ""TargetVolumeID"", ""B"".""State"" AS ""TargetVolumeState"" @@ -731,12 +626,12 @@ DROP TABLE ""{tablename}""; DELETE FROM ""IndexBlockLink"" WHERE ""BlockVolumeID"" IN (SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Type"" = '{RemoteVolumeType.Blocks}' AND ""State"" = '{RemoteVolumeState.Temporary}' AND ""ID"" NOT IN (SELECT DISTINCT ""VolumeID"" FROM ""Block"")); DELETE FROM ""DuplicateBlock"" WHERE ""VolumeID"" IN (SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Type"" = '{RemoteVolumeType.Blocks}' AND ""State"" = '{RemoteVolumeState.Temporary}' AND ""ID"" NOT IN (SELECT DISTINCT ""VolumeID"" FROM ""Block"")); DELETE FROM ""RemoteVolume"" WHERE ""Type"" = '{RemoteVolumeType.Blocks}' AND ""State"" = '{RemoteVolumeState.Temporary}' AND ""ID"" NOT IN (SELECT DISTINCT ""VolumeID"" FROM ""Block""); -"; +"); // We could delete these, but we don't have to, so we keep them around until the next compact is done // UPDATE ""RemoteVolume"" SET ""State"" = ""{3}"" WHERE ""Type"" = ""{5}"" AND ""ID"" NOT IN (SELECT ""IndexVolumeID"" FROM ""IndexBlockLink""); - var countsql = $@"SELECT COUNT(*) FROM ""RemoteVolume"" WHERE ""State"" = '{RemoteVolumeState.Temporary}' AND ""Type"" = '{RemoteVolumeType.Blocks}' "; + var countsql = FormatInvariant($@"SELECT COUNT(*) FROM ""RemoteVolume"" WHERE ""State"" = '{RemoteVolumeState.Temporary}' AND ""Type"" = '{RemoteVolumeType.Blocks}' "); using (var cmd = m_connection.CreateCommand()) { @@ -756,7 +651,7 @@ DELETE FROM ""RemoteVolume"" WHERE ""Type"" = '{RemoteVolumeType.Blocks}' AND "" /// Move blocks that are not referenced by any files to DeletedBlock table. /// /// Needs to be called after the last FindMissingBlocklistHashes, otherwise the tables are not up to date. - public void CleanupDeletedBlocks(System.Data.IDbTransaction transaction) + public void CleanupDeletedBlocks(IDbTransaction transaction) { // Find out which blocks are deleted and move them into DeletedBlock, so that compact notices these blocks are empty // Deleted blocks do not appear in the BlocksetEntry and not in the BlocklistHash table @@ -767,15 +662,15 @@ DELETE FROM ""RemoteVolume"" WHERE ""Type"" = '{RemoteVolumeType.Blocks}' AND "" using (var cmd = m_connection.CreateCommand(tr.Parent)) { // 1. Select blocks not used by any file and not as a blocklist into temporary table - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{tmptablename}"" AS SELECT ""Block"".""ID"", ""Block"".""Hash"", ""Block"".""Size"", ""Block"".""VolumeID"" FROM ""Block"" WHERE ""Block"".""ID"" NOT IN (SELECT ""BlocksetEntry"".""BlockID"" FROM ""BlocksetEntry"") - AND ""Block"".""Hash"" NOT IN (SELECT ""BlocklistHash"".""Hash"" FROM ""BlocklistHash"")", tmptablename)); + AND ""Block"".""Hash"" NOT IN (SELECT ""BlocklistHash"".""Hash"" FROM ""BlocklistHash"")")); // 2. Insert blocks into DeletedBlock table - cmd.ExecuteNonQuery(string.Format(@"INSERT INTO ""DeletedBlock"" (""Hash"", ""Size"", ""VolumeID"") SELECT ""Hash"", ""Size"", ""VolumeID"" FROM ""{0}""", tmptablename)); + cmd.ExecuteNonQuery(FormatInvariant($@"INSERT INTO ""DeletedBlock"" (""Hash"", ""Size"", ""VolumeID"") SELECT ""Hash"", ""Size"", ""VolumeID"" FROM ""{tmptablename}""")); // 3. Remove blocks from Block table - cmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""Block"" WHERE ""ID"" IN (SELECT ""ID"" FROM ""{0}"")", tmptablename)); - cmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}""", tmptablename)); + cmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""Block"" WHERE ""ID"" IN (SELECT ""ID"" FROM ""{tmptablename}"")")); + cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{tmptablename}""")); tr.Commit(); } @@ -789,20 +684,32 @@ DELETE FROM ""RemoteVolume"" WHERE ""Type"" = '{RemoteVolumeType.Blocks}' AND "" if (m_tempblocklist != null) try { - cmd.CommandText = string.Format(@"DROP TABLE IF EXISTS ""{0}""", m_tempblocklist); + cmd.CommandText = FormatInvariant(@$"DROP TABLE IF EXISTS ""{m_tempblocklist}"""); cmd.ExecuteNonQuery(); } - catch { } - finally { m_tempblocklist = null; } + catch (Exception ex) + { + Logging.Log.WriteWarningMessage(LOGTAG, "FailedToDropTempBlocklist", ex, "Failed to drop temporary blocklist table {0}", m_tempblocklist); + } + finally + { + m_tempblocklist = null; + } if (m_tempsmalllist != null) try { - cmd.CommandText = string.Format(@"DROP TABLE IF EXISTS ""{0}""", m_tempsmalllist); + cmd.CommandText = FormatInvariant(@$"DROP TABLE IF EXISTS ""{m_tempsmalllist}"""); cmd.ExecuteNonQuery(); } - catch { } - finally { m_tempsmalllist = null; } + catch (Exception ex) + { + Logging.Log.WriteWarningMessage(LOGTAG, "FailedToDropTempSmalllist", ex, "Failed to drop temporary smalllist table {0}", m_tempsmalllist); + } + finally + { + m_tempsmalllist = null; + } } diff --git a/Duplicati/Library/Main/Database/LocalRepairDatabase.cs b/Duplicati/Library/Main/Database/LocalRepairDatabase.cs index 421115a75..1ef96055d 100644 --- a/Duplicati/Library/Main/Database/LocalRepairDatabase.cs +++ b/Duplicati/Library/Main/Database/LocalRepairDatabase.cs @@ -1,28 +1,27 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; -using System.Linq; +using System.Data; using System.Collections.Generic; -using System.Security.Cryptography; using Duplicati.Library.Utility; namespace Duplicati.Library.Main.Database @@ -37,17 +36,17 @@ namespace Duplicati.Library.Main.Database public LocalRepairDatabase(string path) : base(path, "Repair", true) { - + } - + public long GetFilesetIdFromRemotename(string filelist) { - using(var cmd = m_connection.CreateCommand()) + using (var cmd = m_connection.CreateCommand()) { var filesetid = cmd.ExecuteScalarInt64(@"SELECT ""Fileset"".""ID"" FROM ""Fileset"",""RemoteVolume"" WHERE ""Fileset"".""VolumeID"" = ""RemoteVolume"".""ID"" AND ""RemoteVolume"".""Name"" = ?", -1, filelist); if (filesetid == -1) - throw new Exception(string.Format("No such remote file: {0}", filelist)); - + throw new Exception($"No such remote file: {filelist}"); + return filesetid; } } @@ -57,48 +56,48 @@ namespace Duplicati.Library.Main.Database string File { get; } long Offset { get; } } - - public interface IBlockWithSources : LocalBackupDatabase.IBlock + + public interface IBlockWithSources : IBlock { IEnumerable Sources { get; } } - - private class BlockWithSources : LocalBackupDatabase.Block, IBlockWithSources + + private class BlockWithSources : Block, IBlockWithSources { private class BlockSource : IBlockSource { public string File { get; private set; } public long Offset { get; private set; } - + public BlockSource(string file, long offset) { this.File = file; this.Offset = offset; - + } } - private readonly System.Data.IDataReader m_rd; + private readonly IDataReader m_rd; public bool Done { get; private set; } - - public BlockWithSources(System.Data.IDataReader rd) + + public BlockWithSources(IDataReader rd) : base(rd.GetString(0), rd.GetInt64(1)) { m_rd = rd; Done = !m_rd.Read(); } - + public IEnumerable Sources { get { if (Done) yield break; - + var cur = new BlockSource(m_rd.GetString(2), m_rd.GetInt64(3)); var file = cur.File; - - while(!Done && cur.File == file) + + while (!Done && cur.File == file) { yield return cur; Done = m_rd.Read(); @@ -108,13 +107,13 @@ namespace Duplicati.Library.Main.Database } } } - + private class RemoteVolume : IRemoteVolume { public string Name { get; private set; } public string Hash { get; private set; } public long Size { get; private set; } - + public RemoteVolume(string name, string hash, long size) { this.Name = name; @@ -122,14 +121,14 @@ namespace Duplicati.Library.Main.Database this.Size = size; } } - + public IEnumerable GetBlockVolumesFromIndexName(string name) { - using(var cmd = m_connection.CreateCommand()) - foreach(var rd in cmd.ExecuteReaderEnumerable(@"SELECT ""Name"", ""Hash"", ""Size"" FROM ""RemoteVolume"" WHERE ""ID"" IN (SELECT ""BlockVolumeID"" FROM ""IndexBlockLink"" WHERE ""IndexVolumeID"" IN (SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Name"" = ?))", name)) + using (var cmd = m_connection.CreateCommand()) + foreach (var rd in cmd.ExecuteReaderEnumerable(@"SELECT ""Name"", ""Hash"", ""Size"" FROM ""RemoteVolume"" WHERE ""ID"" IN (SELECT ""BlockVolumeID"" FROM ""IndexBlockLink"" WHERE ""IndexVolumeID"" IN (SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Name"" = ?))", name)) yield return new RemoteVolume(rd.GetString(0), rd.ConvertValueToString(1), rd.ConvertValueToInt64(2)); } - + public interface IMissingBlockList : IDisposable { bool SetBlockRestored(string hash, long size); @@ -138,40 +137,36 @@ namespace Duplicati.Library.Main.Database IEnumerable GetFilesetsUsingMissingBlocks(); IEnumerable GetMissingBlockSources(); } - + private class MissingBlockList : IMissingBlockList { - private readonly System.Data.IDbConnection m_connection; + private readonly IDbConnection m_connection; private readonly TemporaryTransactionWrapper m_transaction; - private System.Data.IDbCommand m_insertCommand; + private IDbCommand m_insertCommand; private string m_tablename; private readonly string m_volumename; - - public MissingBlockList(string volumename, System.Data.IDbConnection connection, System.Data.IDbTransaction transaction) + + public MissingBlockList(string volumename, IDbConnection connection, IDbTransaction transaction) { m_connection = connection; m_transaction = new TemporaryTransactionWrapper(m_connection, transaction); m_volumename = volumename; var tablename = "MissingBlocks-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); - using(var cmd = m_connection.CreateCommand()) + using (var cmd = m_connection.CreateCommand(m_transaction.Parent)) { - cmd.Transaction = m_transaction.Parent; - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" (""Hash"" TEXT NOT NULL, ""Size"" INTEGER NOT NULL, ""Restored"" INTEGER NOT NULL) ", tablename)); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{tablename}"" (""Hash"" TEXT NOT NULL, ""Size"" INTEGER NOT NULL, ""Restored"" INTEGER NOT NULL) ")); m_tablename = tablename; - - var blockCount = cmd.ExecuteNonQuery(string.Format(@"INSERT INTO ""{0}"" (""Hash"", ""Size"", ""Restored"") SELECT DISTINCT ""Block"".""Hash"", ""Block"".""Size"", 0 AS ""Restored"" FROM ""Block"",""Remotevolume"" WHERE ""Block"".""VolumeID"" = ""Remotevolume"".""ID"" AND ""Remotevolume"".""Name"" = ? ", m_tablename), volumename); - if (blockCount == 0) - throw new Exception(string.Format("Unexpected empty block volume: {0}", volumename)); - cmd.ExecuteNonQuery(string.Format(@"CREATE UNIQUE INDEX ""{0}-Ix"" ON ""{0}"" (""Hash"", ""Size"", ""Restored"")", tablename)); + var blockCount = cmd.ExecuteNonQuery(FormatInvariant($@"INSERT INTO ""{m_tablename}"" (""Hash"", ""Size"", ""Restored"") SELECT DISTINCT ""Block"".""Hash"", ""Block"".""Size"", 0 AS ""Restored"" FROM ""Block"",""Remotevolume"" WHERE ""Block"".""VolumeID"" = ""Remotevolume"".""ID"" AND ""Remotevolume"".""Name"" = ? "), volumename); + if (blockCount == 0) + throw new Exception($"Unexpected empty block volume: {0}"); + + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE UNIQUE INDEX ""{tablename}-Ix"" ON ""{tablename}"" (""Hash"", ""Size"", ""Restored"")")); } - m_insertCommand = m_connection.CreateCommand(); - m_insertCommand.Transaction = m_transaction.Parent; - m_insertCommand.CommandText = string.Format(@"UPDATE ""{0}"" SET ""Restored"" = ? WHERE ""Hash"" = ? AND ""Size"" = ? AND ""Restored"" = ? ", tablename); - m_insertCommand.AddParameters(4); + m_insertCommand = m_connection.CreateCommand(m_transaction.Parent, FormatInvariant($@"UPDATE ""{tablename}"" SET ""Restored"" = ? WHERE ""Hash"" = ? AND ""Size"" = ? AND ""Restored"" = ? ")); } - + public bool SetBlockRestored(string hash, long size) { m_insertCommand.SetParameterValue(0, 1); @@ -180,78 +175,75 @@ namespace Duplicati.Library.Main.Database m_insertCommand.SetParameterValue(3, 0); return m_insertCommand.ExecuteNonQuery() == 1; } - + public IEnumerable GetSourceFilesWithBlocks(long blocksize) { - using(var cmd = m_connection.CreateCommand(m_transaction.Parent)) - using(var rd = cmd.ExecuteReader(string.Format(@"SELECT DISTINCT ""{0}"".""Hash"", ""{0}"".""Size"", ""File"".""Path"", ""BlocksetEntry"".""Index"" * {1} FROM ""{0}"", ""Block"", ""BlocksetEntry"", ""File"" WHERE ""File"".""BlocksetID"" = ""BlocksetEntry"".""BlocksetID"" AND ""Block"".""ID"" = ""BlocksetEntry"".""BlockID"" AND ""{0}"".""Hash"" = ""Block"".""Hash"" AND ""{0}"".""Size"" = ""Block"".""Size"" AND ""{0}"".""Restored"" = ? ", m_tablename, blocksize), 0)) - if (rd.Read()) - { - var bs = new BlockWithSources(rd); - while (!bs.Done) - yield return bs; - } + using (var cmd = m_connection.CreateCommand(m_transaction.Parent)) + using (var rd = cmd.ExecuteReader(FormatInvariant($@"SELECT DISTINCT ""{m_tablename}"".""Hash"", ""{m_tablename}"".""Size"", ""File"".""Path"", ""BlocksetEntry"".""Index"" * {blocksize} FROM ""{m_tablename}"", ""Block"", ""BlocksetEntry"", ""File"" WHERE ""File"".""BlocksetID"" = ""BlocksetEntry"".""BlocksetID"" AND ""Block"".""ID"" = ""BlocksetEntry"".""BlockID"" AND ""{m_tablename}"".""Hash"" = ""Block"".""Hash"" AND ""{m_tablename}"".""Size"" = ""Block"".""Size"" AND ""{m_tablename}"".""Restored"" = ? "), 0)) + if (rd.Read()) + { + var bs = new BlockWithSources(rd); + while (!bs.Done) + yield return bs; + } } - + public IEnumerable> GetMissingBlocks() { - using(var cmd = m_connection.CreateCommand(m_transaction.Parent)) - foreach(var rd in cmd.ExecuteReaderEnumerable(string.Format(@"SELECT ""{0}"".""Hash"", ""{0}"".""Size"" FROM ""{0}"" WHERE ""{0}"".""Restored"" = ? ", m_tablename), 0)) + using (var cmd = m_connection.CreateCommand(m_transaction.Parent)) + foreach (var rd in cmd.ExecuteReaderEnumerable(FormatInvariant($@"SELECT ""{m_tablename}"".""Hash"", ""{m_tablename}"".""Size"" FROM ""{m_tablename}"" WHERE ""{m_tablename}"".""Restored"" = ? "), 0)) yield return new KeyValuePair(rd.ConvertValueToString(0), rd.ConvertValueToInt64(1)); } - + public IEnumerable GetFilesetsUsingMissingBlocks() { - var blocks = @"SELECT DISTINCT ""FileLookup"".""ID"" AS ID FROM ""{0}"", ""Block"", ""Blockset"", ""BlocksetEntry"", ""FileLookup"" WHERE ""Block"".""Hash"" = ""{0}"".""Hash"" AND ""Block"".""Size"" = ""{0}"".""Size"" AND ""BlocksetEntry"".""BlockID"" = ""Block"".""ID"" AND ""BlocksetEntry"".""BlocksetID"" = ""Blockset"".""ID"" AND ""FileLookup"".""BlocksetID"" = ""Blockset"".""ID"" "; - var blocklists = @"SELECT DISTINCT ""FileLookup"".""ID"" AS ID FROM ""{0}"", ""Block"", ""Blockset"", ""BlocklistHash"", ""FileLookup"" WHERE ""Block"".""Hash"" = ""{0}"".""Hash"" AND ""Block"".""Size"" = ""{0}"".""Size"" AND ""BlocklistHash"".""Hash"" = ""Block"".""Hash"" AND ""BlocklistHash"".""BlocksetID"" = ""Blockset"".""ID"" AND ""FileLookup"".""BlocksetID"" = ""Blockset"".""ID"" "; - - var cmdtxt = @"SELECT DISTINCT ""RemoteVolume"".""Name"", ""RemoteVolume"".""Hash"", ""RemoteVolume"".""Size"" FROM ""RemoteVolume"", ""FilesetEntry"", ""Fileset"" WHERE ""RemoteVolume"".""ID"" = ""Fileset"".""VolumeID"" AND ""Fileset"".""ID"" = ""FilesetEntry"".""FilesetID"" AND ""RemoteVolume"".""Type"" = ? AND ""FilesetEntry"".""FileID"" IN (SELECT DISTINCT ""ID"" FROM ( " + blocks + " UNION " + blocklists + " ))"; - - using(var cmd = m_connection.CreateCommand(m_transaction.Parent)) - foreach(var rd in cmd.ExecuteReaderEnumerable(string.Format(cmdtxt, m_tablename), RemoteVolumeType.Files.ToString())) + var blocks = FormatInvariant($@"SELECT DISTINCT ""FileLookup"".""ID"" AS ID FROM ""{m_tablename}"", ""Block"", ""Blockset"", ""BlocksetEntry"", ""FileLookup"" WHERE ""Block"".""Hash"" = ""{m_tablename}"".""Hash"" AND ""Block"".""Size"" = ""{m_tablename}"".""Size"" AND ""BlocksetEntry"".""BlockID"" = ""Block"".""ID"" AND ""BlocksetEntry"".""BlocksetID"" = ""Blockset"".""ID"" AND ""FileLookup"".""BlocksetID"" = ""Blockset"".""ID"" "); + var blocklists = FormatInvariant($@"SELECT DISTINCT ""FileLookup"".""ID"" AS ID FROM ""{m_tablename}"", ""Block"", ""Blockset"", ""BlocklistHash"", ""FileLookup"" WHERE ""Block"".""Hash"" = ""{m_tablename}"".""Hash"" AND ""Block"".""Size"" = ""{m_tablename}"".""Size"" AND ""BlocklistHash"".""Hash"" = ""Block"".""Hash"" AND ""BlocklistHash"".""BlocksetID"" = ""Blockset"".""ID"" AND ""FileLookup"".""BlocksetID"" = ""Blockset"".""ID"" "); + var cmdtxt = FormatInvariant($@"SELECT DISTINCT ""RemoteVolume"".""Name"", ""RemoteVolume"".""Hash"", ""RemoteVolume"".""Size"" FROM ""RemoteVolume"", ""FilesetEntry"", ""Fileset"" WHERE ""RemoteVolume"".""ID"" = ""Fileset"".""VolumeID"" AND ""Fileset"".""ID"" = ""FilesetEntry"".""FilesetID"" AND ""RemoteVolume"".""Type"" = ? AND ""FilesetEntry"".""FileID"" IN (SELECT DISTINCT ""ID"" FROM ( {blocks} UNION {blocklists} ))"); + + using (var cmd = m_connection.CreateCommand(m_transaction.Parent)) + foreach (var rd in cmd.ExecuteReaderEnumerable(cmdtxt, RemoteVolumeType.Files.ToString())) yield return new RemoteVolume(rd.GetString(0), rd.ConvertValueToString(1), rd.ConvertValueToInt64(2)); } - + public IEnumerable GetMissingBlockSources() { - using(var cmd = m_connection.CreateCommand(m_transaction.Parent)) - foreach(var rd in cmd.ExecuteReaderEnumerable(string.Format(@"SELECT DISTINCT ""RemoteVolume"".""Name"", ""RemoteVolume"".""Hash"", ""RemoteVolume"".""Size"" FROM ""RemoteVolume"", ""Block"", ""{0}"" WHERE ""Block"".""Hash"" = ""{0}"".""Hash"" AND ""Block"".""Size"" = ""{0}"".""Size"" AND ""Block"".""VolumeID"" = ""RemoteVolume"".""ID"" AND ""Remotevolume"".""Name"" != ? ", m_tablename), m_volumename)) + using (var cmd = m_connection.CreateCommand(m_transaction.Parent)) + foreach (var rd in cmd.ExecuteReaderEnumerable(FormatInvariant($@"SELECT DISTINCT ""RemoteVolume"".""Name"", ""RemoteVolume"".""Hash"", ""RemoteVolume"".""Size"" FROM ""RemoteVolume"", ""Block"", ""{m_tablename}"" WHERE ""Block"".""Hash"" = ""{m_tablename}"".""Hash"" AND ""Block"".""Size"" = ""{m_tablename}"".""Size"" AND ""Block"".""VolumeID"" = ""RemoteVolume"".""ID"" AND ""Remotevolume"".""Name"" != ? "), m_volumename)) yield return new RemoteVolume(rd.GetString(0), rd.ConvertValueToString(1), rd.ConvertValueToInt64(2)); } - + public void Dispose() { if (m_tablename != null) { try { - using(var cmd = m_connection.CreateCommand(m_transaction.Parent)) - cmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"" ", m_transaction)); + using (var cmd = m_connection.CreateCommand(m_transaction.Parent)) + cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{m_tablename}"" "), m_transaction); } catch { } finally { m_tablename = null; } } - + if (m_insertCommand != null) try { m_insertCommand.Dispose(); } - catch {} + catch { } finally { m_insertCommand = null; } } } - - public IMissingBlockList CreateBlockList(string volumename, System.Data.IDbTransaction transaction = null) + + public IMissingBlockList CreateBlockList(string volumename, IDbTransaction transaction = null) { return new MissingBlockList(volumename, m_connection, transaction); } public void FixDuplicateMetahash() { - using(var tr = m_connection.BeginTransaction()) - using(var cmd = m_connection.CreateCommand(tr)) + using (var tr = m_connection.BeginTransaction()) + using (var cmd = m_connection.CreateCommand(tr)) { - cmd.Transaction = tr; - - var sql_count = + var sql_count = @"SELECT COUNT(*) FROM (" + @" SELECT DISTINCT c1 FROM (" + @"SELECT COUNT(*) AS ""C1"" FROM (SELECT DISTINCT ""BlocksetID"" FROM ""Metadataset"") UNION SELECT COUNT(*) AS ""C1"" FROM ""Metadataset"" " + @@ -265,41 +257,40 @@ namespace Duplicati.Library.Main.Database var tablename = "TmpFile-" + Guid.NewGuid().ToString("N"); - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" AS SELECT * FROM ""File""", tablename)); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{tablename}"" AS SELECT * FROM ""File""")); var sql = @"SELECT ""A"".""ID"", ""B"".""BlocksetID"" FROM (SELECT MIN(""ID"") AS ""ID"", COUNT(""ID"") AS ""Duplicates"" FROM ""Metadataset"" GROUP BY ""BlocksetID"") ""A"", ""Metadataset"" ""B"" WHERE ""A"".""Duplicates"" > 1 AND ""A"".""ID"" = ""B"".""ID"""; - using(var c2 = m_connection.CreateCommand(tr)) + using (var c2 = m_connection.CreateCommand(tr)) { - c2.CommandText = string.Format(@"UPDATE ""{0}"" SET ""MetadataID"" = ? WHERE ""MetadataID"" IN (SELECT ""ID"" FROM ""Metadataset"" WHERE ""BlocksetID"" = ?)", tablename); + c2.CommandText = FormatInvariant($@"UPDATE ""{tablename}"" SET ""MetadataID"" = ? WHERE ""MetadataID"" IN (SELECT ""ID"" FROM ""Metadataset"" WHERE ""BlocksetID"" = ?)"); c2.CommandText += @"; DELETE FROM ""Metadataset"" WHERE ""BlocksetID"" = ? AND ""ID"" != ?"; - using(var rd = cmd.ExecuteReader(sql)) + using (var rd = cmd.ExecuteReader(sql)) while (rd.Read()) c2.ExecuteNonQuery(null, rd.GetValue(0), rd.GetValue(1), rd.GetValue(1), rd.GetValue(0)); } - sql = string.Format(@"SELECT ""ID"", ""Path"", ""BlocksetID"", ""MetadataID"", ""Entries"" FROM ( - SELECT MIN(""ID"") AS ""ID"", ""Path"", ""BlocksetID"", ""MetadataID"", COUNT(*) as ""Entries"" FROM ""{0}"" GROUP BY ""Path"", ""BlocksetID"", ""MetadataID"") - WHERE ""Entries"" > 1 ORDER BY ""ID""", tablename); + sql = FormatInvariant($@"SELECT ""ID"", ""Path"", ""BlocksetID"", ""MetadataID"", ""Entries"" FROM ( + SELECT MIN(""ID"") AS ""ID"", ""Path"", ""BlocksetID"", ""MetadataID"", COUNT(*) as ""Entries"" FROM ""{tablename}"" GROUP BY ""Path"", ""BlocksetID"", ""MetadataID"") + WHERE ""Entries"" > 1 ORDER BY ""ID"""); - using(var c2 = m_connection.CreateCommand()) + using (var c2 = m_connection.CreateCommand(tr)) { - c2.Transaction = tr; - c2.CommandText = string.Format(@"UPDATE ""FilesetEntry"" SET ""FileID"" = ? WHERE ""FileID"" IN (SELECT ""ID"" FROM ""{0}"" WHERE ""Path"" = ? AND ""BlocksetID"" = ? AND ""MetadataID"" = ?)", tablename); - c2.CommandText += string.Format(@"; DELETE FROM ""{0}"" WHERE ""Path"" = ? AND ""BlocksetID"" = ? AND ""MetadataID"" = ? AND ""ID"" != ?", tablename); - foreach(var rd in cmd.ExecuteReaderEnumerable(sql)) + c2.CommandText = FormatInvariant($@"UPDATE ""FilesetEntry"" SET ""FileID"" = ? WHERE ""FileID"" IN (SELECT ""ID"" FROM ""{tablename}"" WHERE ""Path"" = ? AND ""BlocksetID"" = ? AND ""MetadataID"" = ?)"); + c2.CommandText += FormatInvariant($@"; DELETE FROM ""{tablename}"" WHERE ""Path"" = ? AND ""BlocksetID"" = ? AND ""MetadataID"" = ? AND ""ID"" != ?"); + foreach (var rd in cmd.ExecuteReaderEnumerable(sql)) c2.ExecuteNonQuery(null, rd.GetValue(0), rd.GetValue(1), rd.GetValue(2), rd.GetValue(3), rd.GetValue(1), rd.GetValue(2), rd.GetValue(3), rd.GetValue(0)); } - cmd.ExecuteNonQuery(string.Format(@"DELETE FROM ""FileLookup"" WHERE ""ID"" NOT IN (SELECT ""ID"" FROM ""{0}"") ", tablename)); - cmd.ExecuteNonQuery(string.Format(@"CREATE INDEX ""{0}-Ix"" ON ""{0}"" (""ID"", ""MetadataID"")", tablename)); - cmd.ExecuteNonQuery(string.Format(@"UPDATE ""FileLookup"" SET ""MetadataID"" = (SELECT ""MetadataID"" FROM ""{0}"" A WHERE ""A"".""ID"" = ""FileLookup"".""ID"") ", tablename)); - cmd.ExecuteNonQuery(string.Format(@"DROP TABLE ""{0}"" ", tablename)); + cmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""FileLookup"" WHERE ""ID"" NOT IN (SELECT ""ID"" FROM ""{tablename}"") ")); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE INDEX ""{tablename}-Ix"" ON ""{tablename}"" (""ID"", ""MetadataID"")")); + cmd.ExecuteNonQuery(FormatInvariant($@"UPDATE ""FileLookup"" SET ""MetadataID"" = (SELECT ""MetadataID"" FROM ""{tablename}"" A WHERE ""A"".""ID"" = ""FileLookup"".""ID"") ")); + cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE ""{tablename}"" ")); cmd.CommandText = sql_count; x = cmd.ExecuteScalarInt64(0); if (x > 1) - throw new Duplicati.Library.Interface.UserInformationException("Repair failed, there are still duplicate metadatahashes!", "DuplicateHashesRepairFailed"); + throw new Interface.UserInformationException("Repair failed, there are still duplicate metadatahashes!", "DuplicateHashesRepairFailed"); Logging.Log.WriteInformationMessage(LOGTAG, "DuplicateMetadataHashesFixed", "Duplicate metadatahashes repaired succesfully"); tr.Commit(); @@ -309,8 +300,8 @@ namespace Duplicati.Library.Main.Database public void FixDuplicateFileentries() { - using(var tr = m_connection.BeginTransaction()) - using(var cmd = m_connection.CreateCommand(tr)) + using (var tr = m_connection.BeginTransaction()) + using (var cmd = m_connection.CreateCommand(tr)) { var sql_count = @"SELECT COUNT(*) FROM (SELECT ""PrefixID"", ""Path"", ""BlocksetID"", ""MetadataID"", COUNT(*) as ""Duplicates"" FROM ""FileLookup"" GROUP BY ""PrefixID"", ""Path"", ""BlocksetID"", ""MetadataID"") WHERE ""Duplicates"" > 1"; @@ -323,18 +314,18 @@ namespace Duplicati.Library.Main.Database SELECT MIN(""ID"") AS ""ID"", ""PrefixID"", ""Path"", ""BlocksetID"", ""MetadataID"", COUNT(*) as ""Entries"" FROM ""FileLookup"" GROUP BY ""PrefixID"", ""Path"", ""BlocksetID"", ""MetadataID"") WHERE ""Entries"" > 1 ORDER BY ""ID"""; - using(var c2 = m_connection.CreateCommand(tr)) + using (var c2 = m_connection.CreateCommand(tr)) { c2.CommandText = @"UPDATE ""FilesetEntry"" SET ""FileID"" = ? WHERE ""FileID"" IN (SELECT ""ID"" FROM ""FileLookup"" WHERE ""PrefixID"" = ? AND ""Path"" = ? AND ""BlocksetID"" = ? AND ""MetadataID"" = ?)"; c2.CommandText += @"; DELETE FROM ""FileLookup"" WHERE ""PrefixID"" = ? AND ""Path"" = ? AND ""BlocksetID"" = ? AND ""MetadataID"" = ? AND ""ID"" != ?"; - foreach(var rd in cmd.ExecuteReaderEnumerable(sql)) + foreach (var rd in cmd.ExecuteReaderEnumerable(sql)) c2.ExecuteNonQuery(null, rd.GetValue(0), rd.GetValue(1), rd.GetValue(2), rd.GetValue(3), rd.GetValue(4), rd.GetValue(1), rd.GetValue(2), rd.GetValue(3), rd.GetValue(4), rd.GetValue(0)); } cmd.CommandText = sql_count; x = cmd.ExecuteScalarInt64(0); if (x > 1) - throw new Duplicati.Library.Interface.UserInformationException("Repair failed, there are still duplicate file entries!", "DuplicateFilesRepairFailed"); + throw new Interface.UserInformationException("Repair failed, there are still duplicate file entries!", "DuplicateFilesRepairFailed"); Logging.Log.WriteInformationMessage(LOGTAG, "DuplicateFileEntriesFixed", "Duplicate file entries repaired succesfully"); tr.Commit(); @@ -347,31 +338,31 @@ namespace Duplicati.Library.Main.Database { var blocklistbuffer = new byte[blocksize]; - using(var tr = m_connection.BeginTransaction()) - using(var cmd = m_connection.CreateCommand(tr)) - using(var blockhasher = HashFactory.CreateHasher(blockhashalgorithm)) + using (var tr = m_connection.BeginTransaction()) + using (var cmd = m_connection.CreateCommand(tr)) + using (var blockhasher = HashFactory.CreateHasher(blockhashalgorithm)) { var hashsize = blockhasher.HashSize / 8; - var sql = string.Format(@"SELECT * FROM (SELECT ""N"".""BlocksetID"", ((""N"".""BlockCount"" + {0} - 1) / {0}) AS ""BlocklistHashCountExpected"", CASE WHEN ""G"".""BlocklistHashCount"" IS NULL THEN 0 ELSE ""G"".""BlocklistHashCount"" END AS ""BlocklistHashCountActual"" FROM (SELECT ""BlocksetID"", COUNT(*) AS ""BlockCount"" FROM ""BlocksetEntry"" GROUP BY ""BlocksetID"") ""N"" LEFT OUTER JOIN (SELECT ""BlocksetID"", COUNT(*) AS ""BlocklistHashCount"" FROM ""BlocklistHash"" GROUP BY ""BlocksetID"") ""G"" ON ""N"".""BlocksetID"" = ""G"".""BlocksetID"" WHERE ""N"".""BlockCount"" > 1) WHERE ""BlocklistHashCountExpected"" != ""BlocklistHashCountActual""", blocksize / hashsize); + var sql = FormatInvariant($@"SELECT * FROM (SELECT ""N"".""BlocksetID"", ((""N"".""BlockCount"" + {blocksize / hashsize} - 1) / {blocksize / hashsize}) AS ""BlocklistHashCountExpected"", CASE WHEN ""G"".""BlocklistHashCount"" IS NULL THEN 0 ELSE ""G"".""BlocklistHashCount"" END AS ""BlocklistHashCountActual"" FROM (SELECT ""BlocksetID"", COUNT(*) AS ""BlockCount"" FROM ""BlocksetEntry"" GROUP BY ""BlocksetID"") ""N"" LEFT OUTER JOIN (SELECT ""BlocksetID"", COUNT(*) AS ""BlocklistHashCount"" FROM ""BlocklistHash"" GROUP BY ""BlocksetID"") ""G"" ON ""N"".""BlocksetID"" = ""G"".""BlocksetID"" WHERE ""N"".""BlockCount"" > 1) WHERE ""BlocklistHashCountExpected"" != ""BlocklistHashCountActual"""); var countsql = @"SELECT COUNT(*) FROM (" + sql + @")"; var itemswithnoblocklisthash = cmd.ExecuteScalarInt64(countsql, 0); if (itemswithnoblocklisthash != 0) { Logging.Log.WriteInformationMessage(LOGTAG, "MissingBlocklistHashes", "Found {0} missing blocklisthash entries, repairing", itemswithnoblocklisthash); - using(var c2 = m_connection.CreateCommand(tr)) - using(var c3 = m_connection.CreateCommand(tr)) - using(var c4 = m_connection.CreateCommand(tr)) - using(var c5 = m_connection.CreateCommand(tr)) - using(var c6 = m_connection.CreateCommand(tr)) + using (var c2 = m_connection.CreateCommand(tr)) + using (var c3 = m_connection.CreateCommand(tr)) + using (var c4 = m_connection.CreateCommand(tr)) + using (var c5 = m_connection.CreateCommand(tr)) + using (var c6 = m_connection.CreateCommand(tr)) { c3.CommandText = @"INSERT INTO ""BlocklistHash"" (""BlocksetID"", ""Index"", ""Hash"") VALUES (?, ?, ?) "; c4.CommandText = @"SELECT COUNT(*) FROM ""Block"" WHERE ""Hash"" = ? AND ""Size"" = ?"; c5.CommandText = @"SELECT ""ID"" FROM ""DeletedBlock"" WHERE ""Hash"" = ? AND ""Size"" = ? AND ""VolumeID"" IN (SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Type"" = ? AND (""State"" = ? OR ""State"" = ?))"; c6.CommandText = @"INSERT INTO ""Block"" (""Hash"", ""Size"", ""VolumeID"") SELECT ""Hash"", ""Size"", ""VolumeID"" FROM ""DeletedBlock"" WHERE ""ID"" = ? LIMIT 1; DELETE FROM ""DeletedBlock"" WHERE ""ID"" = ?;"; - foreach(var e in cmd.ExecuteReaderEnumerable(sql)) + foreach (var e in cmd.ExecuteReaderEnumerable(sql)) { var blocksetid = e.ConvertValueToInt64(0); var ix = 0L; @@ -379,7 +370,7 @@ namespace Duplicati.Library.Main.Database c2.ExecuteNonQuery(@"DELETE FROM ""BlocklistHash"" WHERE ""BlocksetID"" = ?", blocksetid); - foreach(var h in c2.ExecuteReaderEnumerable(@"SELECT ""A"".""Hash"" FROM ""Block"" ""A"", ""BlocksetEntry"" ""B"" WHERE ""A"".""ID"" = ""B"".""BlockID"" AND ""B"".""BlocksetID"" = ? ORDER BY ""B"".""Index""", blocksetid)) + foreach (var h in c2.ExecuteReaderEnumerable(@"SELECT ""A"".""Hash"" FROM ""Block"" ""A"", ""BlocksetEntry"" ""B"" WHERE ""A"".""ID"" = ""B"".""BlockID"" AND ""B"".""BlocksetID"" = ? ORDER BY ""B"".""Index""", blocksetid)) { var tmp = Convert.FromBase64String(h.GetString(0)); if (blocklistbuffer.Length - blocklistoffset < tmp.Length) @@ -391,12 +382,12 @@ namespace Duplicati.Library.Main.Database { var c = c5.ExecuteScalarInt64(null, -1, blkey, blocklistoffset, RemoteVolumeType.Blocks.ToString(), RemoteVolumeState.Uploaded.ToString(), RemoteVolumeState.Verified.ToString()); if (c <= 0) - throw new Exception(string.Format("Missing block for blocklisthash: {0}", blkey)); + throw new Exception($"Missing block for blocklisthash: {blkey}"); else { var rc = c6.ExecuteNonQuery(null, c, c); if (rc != 2) - throw new Exception(string.Format("Unexpected update count: {0}", rc)); + throw new Exception($"Unexpected update count: {rc}"); } } @@ -420,12 +411,12 @@ namespace Duplicati.Library.Main.Database { var c = c5.ExecuteScalarInt64(null, -1, blkeyfinal, blocklistoffset, RemoteVolumeType.Blocks.ToString(), RemoteVolumeState.Uploaded.ToString(), RemoteVolumeState.Verified.ToString()); if (c == 0) - throw new Exception(string.Format("Missing block for blocklisthash: {0}", blkeyfinal)); + throw new Exception($"Missing block for blocklisthash: {blkeyfinal}"); else { var rc = c6.ExecuteNonQuery(null, c, c); if (rc != 2) - throw new Exception(string.Format("Unexpected update count: {0}", rc)); + throw new Exception($"Unexpected update count: {rc}"); } } @@ -438,7 +429,7 @@ namespace Duplicati.Library.Main.Database itemswithnoblocklisthash = cmd.ExecuteScalarInt64(countsql, 0); if (itemswithnoblocklisthash != 0) - throw new Duplicati.Library.Interface.UserInformationException(string.Format("Failed to repair, after repair {0} blocklisthashes were missing", itemswithnoblocklisthash), "MissingBlocklistHashesRepairFailed"); + throw new Interface.UserInformationException($"Failed to repair, after repair {itemswithnoblocklisthash} blocklisthashes were missing", "MissingBlocklistHashesRepairFailed"); Logging.Log.WriteInformationMessage(LOGTAG, "MissingBlocklisthashesRepaired", "Missing blocklisthashes repaired succesfully"); tr.Commit(); @@ -449,8 +440,8 @@ namespace Duplicati.Library.Main.Database public void FixDuplicateBlocklistHashes(long blocksize, long hashsize) { - using(var tr = m_connection.BeginTransaction()) - using(var cmd = m_connection.CreateCommand(tr)) + using (var tr = m_connection.BeginTransaction()) + using (var cmd = m_connection.CreateCommand(tr)) { var dup_sql = @"SELECT * FROM (SELECT ""BlocksetID"", ""Index"", COUNT(*) AS ""EC"" FROM ""BlocklistHash"" GROUP BY ""BlocksetID"", ""Index"") WHERE ""EC"" > 1"; @@ -463,15 +454,15 @@ namespace Duplicati.Library.Main.Database var unique_count = cmd.ExecuteScalarInt64(@"SELECT COUNT(*) FROM (SELECT DISTINCT ""BlocksetID"", ""Index"" FROM ""BlocklistHash"")", 0); - using(var c2 = m_connection.CreateCommand(tr)) + using (var c2 = m_connection.CreateCommand(tr)) { c2.CommandText = @"DELETE FROM ""BlocklistHash"" WHERE rowid IN (SELECT rowid FROM ""BlocklistHash"" WHERE ""BlocksetID"" = ? AND ""Index"" = ? LIMIT ?)"; - foreach(var rd in cmd.ExecuteReaderEnumerable(dup_sql)) + foreach (var rd in cmd.ExecuteReaderEnumerable(dup_sql)) { var expected = rd.GetInt32(2) - 1; var actual = c2.ExecuteNonQuery(null, rd.GetValue(0), rd.GetValue(1), expected); if (actual != expected) - throw new Exception(string.Format("Unexpected number of results after fix, got: {0}, expected: {1}", actual, expected)); + throw new Exception($"Unexpected number of results after fix, got: {actual}, expected: {expected}"); } } @@ -483,15 +474,15 @@ namespace Duplicati.Library.Main.Database var real_count = cmd.ExecuteScalarInt64(@"SELECT Count(*) FROM ""BlocklistHash""", 0); if (real_count != unique_count) - throw new Duplicati.Library.Interface.UserInformationException(string.Format("Failed to repair, result should have been {0} blocklist hashes, but result was {1} blocklist hashes", unique_count, real_count), "DuplicateBlocklistHashesRepairFailed"); + throw new Interface.UserInformationException($"Failed to repair, result should have been {unique_count} blocklist hashes, but result was {real_count} blocklist hashes", "DuplicateBlocklistHashesRepairFailed"); try { VerifyConsistency(blocksize, hashsize, true, tr); } - catch(Exception ex) + catch (Exception ex) { - throw new Duplicati.Library.Interface.UserInformationException("Repaired blocklisthashes, but the database was broken afterwards, rolled back changes", "DuplicateBlocklistHashesRepairFailed", ex); + throw new Interface.UserInformationException("Repaired blocklisthashes, but the database was broken afterwards, rolled back changes", "DuplicateBlocklistHashesRepairFailed", ex); } Logging.Log.WriteInformationMessage(LOGTAG, "DuplicateBlocklistHashesRepaired", "Duplicate blocklisthashes repaired succesfully"); @@ -502,16 +493,16 @@ namespace Duplicati.Library.Main.Database public void CheckAllBlocksAreInVolume(string filename, IEnumerable> blocks) { - using(var tr = m_connection.BeginTransaction()) - using(var cmd = m_connection.CreateCommand(tr)) + using (var tr = m_connection.BeginTransaction()) + using (var cmd = m_connection.CreateCommand(tr)) { var tablename = "ProbeBlocks-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" (""Hash"" TEXT NOT NULL, ""Size"" INTEGER NOT NULL)", tablename)); - cmd.CommandText = string.Format(@"INSERT INTO ""{0}"" (""Hash"", ""Size"") VALUES (?, ?)", tablename); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{tablename}"" (""Hash"" TEXT NOT NULL, ""Size"" INTEGER NOT NULL)")); + cmd.CommandText = FormatInvariant($@"INSERT INTO ""{tablename}"" (""Hash"", ""Size"") VALUES (?, ?)"); cmd.AddParameters(2); - foreach(var kp in blocks) + foreach (var kp in blocks) { cmd.SetParameterValue(0, kp.Key); cmd.SetParameterValue(1, kp.Value); @@ -519,20 +510,20 @@ namespace Duplicati.Library.Main.Database } var id = cmd.ExecuteScalarInt64(@"SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Name"" = ?", -1, filename); - var aliens = cmd.ExecuteScalarInt64(string.Format(@"SELECT COUNT(*) FROM (SELECT ""A"".""VolumeID"" FROM ""{0}"" B LEFT OUTER JOIN ""Block"" A ON ""A"".""Hash"" = ""B"".""Hash"" AND ""A"".""Size"" = ""B"".""Size"") WHERE ""VolumeID"" != ? ", tablename), 0, id); + var aliens = cmd.ExecuteScalarInt64(FormatInvariant($@"SELECT COUNT(*) FROM (SELECT ""A"".""VolumeID"" FROM ""{tablename}"" B LEFT OUTER JOIN ""Block"" A ON ""A"".""Hash"" = ""B"".""Hash"" AND ""A"".""Size"" = ""B"".""Size"") WHERE ""VolumeID"" != ? "), 0, id); - cmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"" ", tablename)); + cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{tablename}"" ")); if (aliens != 0) - throw new Exception(string.Format("Not all blocks were found in {0}", filename)); + throw new Exception($"Not all blocks were found in {filename}"); } } public void CheckBlocklistCorrect(string hash, long length, IEnumerable blocklist, long blocksize, long blockhashlength) { - using(var cmd = m_connection.CreateCommand()) + using (var cmd = m_connection.CreateCommand()) { - var query = string.Format(@" + var query = FormatInvariant($@" SELECT ""C"".""Hash"", ""C"".""Size"" @@ -558,30 +549,43 @@ WHERE AND ""A"".""BlockID"" = ""C"".""ID"" AND - ""A"".""Index"" >= ""B"".""BlocklistHashIndex"" * ({0} / {1}) + ""A"".""Index"" >= ""B"".""BlocklistHashIndex"" * ({blocksize} / {blockhashlength}) AND - ""A"".""Index"" < (""B"".""BlocklistHashIndex"" + 1) * ({0} / {1}) + ""A"".""Index"" < (""B"".""BlocklistHashIndex"" + 1) * ({blocksize} / {blockhashlength}) ORDER BY ""A"".""Index"" -" - ,blocksize, blockhashlength); +"); using (var en = blocklist.GetEnumerator()) { foreach (var r in cmd.ExecuteReaderEnumerable(query, hash, length)) { if (!en.MoveNext()) - throw new Exception(string.Format("Too few entries in source blocklist with hash {0}", hash)); + throw new Exception($"Too few entries in source blocklist with hash {hash}"); if (en.Current != r.GetString(0)) - throw new Exception(string.Format("Mismatch in blocklist with hash {0}", hash)); + throw new Exception($"Mismatch in blocklist with hash {hash}"); } if (en.MoveNext()) - throw new Exception(string.Format("Too many source blocklist entries in {0}", hash)); + throw new Exception($"Too many source blocklist entries in {hash}"); } } } + + public IEnumerable MissingLocalFilesets() + { + using (var cmd = m_connection.CreateCommand()) + foreach (var rd in cmd.ExecuteReaderEnumerable(@"SELECT ""Name"" FROM ""RemoteVolume"" WHERE ""Type"" = ? AND ""State"" NOT IN (?, ?) AND ""ID"" NOT IN (SELECT ""VolumeID"" FROM ""Fileset"")", RemoteVolumeType.Files.ToString(), RemoteVolumeState.Deleting.ToString(), RemoteVolumeState.Deleted.ToString())) + yield return rd.ConvertValueToString(0); + } + + public IEnumerable<(long FilesetID, DateTime Timestamp, bool IsFull)> MissingRemoteFilesets() + { + using (var cmd = m_connection.CreateCommand()) + foreach (var rd in cmd.ExecuteReaderEnumerable(@"SELECT ""ID"", ""Timestamp"", ""IsFullBackup"" FROM ""Fileset"" WHERE ""VolumeID"" NOT IN (SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Type"" = ? AND ""State"" NOT IN (?, ?))", RemoteVolumeType.Files.ToString(), RemoteVolumeState.Deleting.ToString(), RemoteVolumeState.Deleted.ToString())) + yield return (rd.ConvertValueToInt64(0), ParseFromEpochSeconds(rd.ConvertValueToInt64(1)), rd.ConvertValueToInt64(2) == BackupType.FULL_BACKUP); + } } } diff --git a/Duplicati/Library/Main/Database/LocalRestoreDatabase.cs b/Duplicati/Library/Main/Database/LocalRestoreDatabase.cs index fde1e870f..74223dbc6 100644 --- a/Duplicati/Library/Main/Database/LocalRestoreDatabase.cs +++ b/Duplicati/Library/Main/Database/LocalRestoreDatabase.cs @@ -1,24 +1,25 @@ // Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the +// +// 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 +// +// 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 +// +// 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.Data; using System.Linq; @@ -43,6 +44,7 @@ namespace Duplicati.Library.Main.Database /// protected string m_tempfiletable; protected string m_tempblocktable; + protected ConcurrentBag m_connection_pool = []; protected string m_latestblocktable; protected string m_fileprogtable; protected string m_totalprogtable; @@ -80,33 +82,33 @@ namespace Duplicati.Library.Main.Database /// public void CreateProgressTracker(bool createFilesNewlyDoneTracker) { - m_fileprogtable = "FileProgress-" + this.m_temptabsetguid; - m_totalprogtable = "TotalProgress-" + this.m_temptabsetguid; - m_filesnewlydonetable = createFilesNewlyDoneTracker ? "FilesNewlyDone-" + this.m_temptabsetguid : null; + m_fileprogtable = "FileProgress-" + m_temptabsetguid; + m_totalprogtable = "TotalProgress-" + m_temptabsetguid; + m_filesnewlydonetable = createFilesNewlyDoneTracker ? "FilesNewlyDone-" + m_temptabsetguid : null; using (var cmd = m_connection.CreateCommand()) { // How to handle METADATA? - cmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"" ", m_fileprogtable)); - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" (" - + @" ""FileId"" INTEGER PRIMARY KEY " - + @", ""TotalBlocks"" INTEGER NOT NULL, ""TotalSize"" INTEGER NOT NULL " - + @", ""BlocksRestored"" INTEGER NOT NULL, ""SizeRestored"" INTEGER NOT NULL " - + @")", m_fileprogtable)); + cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{m_fileprogtable}"" ")); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{m_fileprogtable}"" ( +""FileId"" INTEGER PRIMARY KEY, +""TotalBlocks"" INTEGER NOT NULL, ""TotalSize"" INTEGER NOT NULL, +""BlocksRestored"" INTEGER NOT NULL, ""SizeRestored"" INTEGER NOT NULL +)")); - cmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"" ", m_totalprogtable)); - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" (" - + @" ""TotalFiles"" INTEGER NOT NULL, ""TotalBlocks"" INTEGER NOT NULL, ""TotalSize"" INTEGER NOT NULL " - + @", ""FilesFullyRestored"" INTEGER NOT NULL, ""FilesPartiallyRestored"" INTEGER NOT NULL " - + @", ""BlocksRestored"" INTEGER NOT NULL, ""SizeRestored"" INTEGER NOT NULL " - + @")", m_totalprogtable)); + cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{m_totalprogtable}"" ")); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{m_totalprogtable}"" ( +""TotalFiles"" INTEGER NOT NULL, ""TotalBlocks"" INTEGER NOT NULL, ""TotalSize"" INTEGER NOT NULL, +""FilesFullyRestored"" INTEGER NOT NULL, ""FilesPartiallyRestored"" INTEGER NOT NULL, +""BlocksRestored"" INTEGER NOT NULL, ""SizeRestored"" INTEGER NOT NULL +)")); if (createFilesNewlyDoneTracker) { - cmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"" ", m_filesnewlydonetable)); - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" (" - + @" ""ID"" INTEGER PRIMARY KEY " - + @")", m_filesnewlydonetable)); + cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{m_filesnewlydonetable}"" ")); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{m_filesnewlydonetable}"" ( +""ID"" INTEGER PRIMARY KEY +)")); } try @@ -116,31 +118,28 @@ namespace Duplicati.Library.Main.Database // Note: FilesNewlyDone is NOT initialized, as in initialization nothing is really new. string sql; - sql = string.Format( - @" INSERT INTO ""{0}"" (""FileId"", ""TotalBlocks"", ""TotalSize"", ""BlocksRestored"", ""SizeRestored"") " - + @" SELECT ""F"".""ID"", IFNULL(COUNT(""B"".""ID""), 0), IFNULL(SUM(""B"".""Size""), 0)" - + @" , IFNULL(COUNT(CASE ""B"".""Restored"" WHEN 1 THEN ""B"".""ID"" ELSE NULL END), 0) " - + @" , IFNULL(SUM(CASE ""B"".""Restored"" WHEN 1 THEN ""B"".""Size"" ELSE 0 END), 0) " - + @" FROM ""{1}"" ""F"" LEFT JOIN ""{2}"" ""B""" // allow for empty files (no data Blocks) - + @" ON ""B"".""FileID"" = ""F"".""ID"" " - + @" WHERE ""B"".""Metadata"" IS NOT 1 " // Use "IS" because of Left Join - + @" GROUP BY ""F"".""ID"" " - , m_fileprogtable, m_tempfiletable, m_tempblocktable); + // We use a LEFT JOIN to allow for empty files (no data Blocks) + sql = FormatInvariant($@" INSERT INTO ""{m_fileprogtable}"" (""FileId"", ""TotalBlocks"", ""TotalSize"", ""BlocksRestored"", ""SizeRestored"") +SELECT ""F"".""ID"", IFNULL(COUNT(""B"".""ID""), 0), IFNULL(SUM(""B"".""Size""), 0) + , IFNULL(COUNT(CASE ""B"".""Restored"" WHEN 1 THEN ""B"".""ID"" ELSE NULL END), 0) + , IFNULL(SUM(CASE ""B"".""Restored"" WHEN 1 THEN ""B"".""Size"" ELSE 0 END), 0) + FROM ""{m_tempfiletable}"" ""F"" LEFT JOIN ""{m_tempblocktable}"" ""B"" + ON ""B"".""FileID"" = ""F"".""ID"" + WHERE ""B"".""Metadata"" IS NOT 1 + GROUP BY ""F"".""ID"" "); // Will be one row per file. cmd.ExecuteNonQuery(sql); - sql = string.Format( - @"INSERT INTO ""{0}"" (" - + @" ""TotalFiles"", ""TotalBlocks"", ""TotalSize"" " - + @", ""FilesFullyRestored"", ""FilesPartiallyRestored"", ""BlocksRestored"", ""SizeRestored""" - + @" ) " - + @" SELECT IFNULL(COUNT(""P"".""FileId""), 0), IFNULL(SUM(""P"".""TotalBlocks""), 0), IFNULL(SUM(""P"".""TotalSize""), 0) " - + @" , IFNULL(COUNT(CASE WHEN ""P"".""BlocksRestored"" = ""P"".""TotalBlocks"" THEN 1 ELSE NULL END), 0) " - + @" , IFNULL(COUNT(CASE WHEN ""P"".""BlocksRestored"" BETWEEN 1 AND ""P"".""TotalBlocks"" - 1 THEN 1 ELSE NULL END), 0) " - + @" , IFNULL(SUM(""P"".""BlocksRestored""), 0), IFNULL(SUM(""P"".""SizeRestored""), 0) " - + @" FROM ""{1}"" ""P"" " - , m_totalprogtable, m_fileprogtable); + sql = FormatInvariant($@"INSERT INTO ""{m_totalprogtable}"" ( + ""TotalFiles"", ""TotalBlocks"", ""TotalSize"" +, ""FilesFullyRestored"", ""FilesPartiallyRestored"", ""BlocksRestored"", ""SizeRestored"" + ) + SELECT IFNULL(COUNT(""P"".""FileId""), 0), IFNULL(SUM(""P"".""TotalBlocks""), 0), IFNULL(SUM(""P"".""TotalSize""), 0) + , IFNULL(COUNT(CASE WHEN ""P"".""BlocksRestored"" = ""P"".""TotalBlocks"" THEN 1 ELSE NULL END), 0) + , IFNULL(COUNT(CASE WHEN ""P"".""BlocksRestored"" BETWEEN 1 AND ""P"".""TotalBlocks"" - 1 THEN 1 ELSE NULL END), 0) + , IFNULL(SUM(""P"".""BlocksRestored""), 0), IFNULL(SUM(""P"".""SizeRestored""), 0) + FROM ""{m_fileprogtable}"" ""P"" "); // Will result in a single line (no support to also track metadata) cmd.ExecuteNonQuery(sql); @@ -152,44 +151,38 @@ namespace Duplicati.Library.Main.Database // with additional INSERT and DELETE triggers. // A trigger to update the file-stat entry each time a block changes restoration state. - sql = string.Format( - @"CREATE TEMPORARY TRIGGER ""TrackRestoredBlocks_{1}"" AFTER UPDATE OF ""Restored"" ON ""{1}"" " - + @" WHEN OLD.""Restored"" != NEW.""Restored"" AND NEW.""Metadata"" = 0 " - + @" BEGIN UPDATE ""{0}"" " - + @" SET ""BlocksRestored"" = ""{0}"".""BlocksRestored"" + (NEW.""Restored"" - OLD.""Restored"") " - + @" , ""SizeRestored"" = ""{0}"".""SizeRestored"" + ((NEW.""Restored"" - OLD.""Restored"") * NEW.Size) " - + @" WHERE ""{0}"".""FileId"" = NEW.""FileID"" " - + @" ; END " - , m_fileprogtable, m_tempblocktable); + sql = FormatInvariant($@"CREATE TEMPORARY TRIGGER ""TrackRestoredBlocks_{m_tempblocktable}"" AFTER UPDATE OF ""Restored"" ON ""{m_tempblocktable}"" +WHEN OLD.""Restored"" != NEW.""Restored"" AND NEW.""Metadata"" = 0 +BEGIN UPDATE ""{m_fileprogtable}"" + SET ""BlocksRestored"" = ""{m_fileprogtable}"".""BlocksRestored"" + (NEW.""Restored"" - OLD.""Restored"") + , ""SizeRestored"" = ""{m_fileprogtable}"".""SizeRestored"" + ((NEW.""Restored"" - OLD.""Restored"") * NEW.Size) + WHERE ""{m_fileprogtable}"".""FileId"" = NEW.""FileID"" +; END "); cmd.ExecuteNonQuery(sql); // A trigger to update total stats each time a file stat changed (nested triggering by file-stats) - sql = string.Format( - @"CREATE TEMPORARY TRIGGER ""UpdateTotalStats_{1}"" AFTER UPDATE ON ""{1}"" " - + @" BEGIN UPDATE ""{0}"" " - + @" SET ""FilesFullyRestored"" = ""{0}"".""FilesFullyRestored"" " - + @" + (CASE WHEN NEW.""BlocksRestored"" = NEW.""TotalBlocks"" THEN 1 ELSE 0 END) " - + @" - (CASE WHEN OLD.""BlocksRestored"" = OLD.""TotalBlocks"" THEN 1 ELSE 0 END) " - + @" , ""FilesPartiallyRestored"" = ""{0}"".""FilesPartiallyRestored"" " - + @" + (CASE WHEN NEW.""BlocksRestored"" BETWEEN 1 AND NEW.""TotalBlocks"" - 1 THEN 1 ELSE 0 END) " - + @" - (CASE WHEN OLD.""BlocksRestored"" BETWEEN 1 AND OLD.""TotalBlocks"" - 1 THEN 1 ELSE 0 END) " - + @" , ""BlocksRestored"" = ""{0}"".""BlocksRestored"" + NEW.""BlocksRestored"" - OLD.""BlocksRestored"" " // simple delta - + @" , ""SizeRestored"" = ""{0}"".""SizeRestored"" + NEW.""SizeRestored"" - OLD.""SizeRestored"" " // simple delta - + @" ; END " - , m_totalprogtable, m_fileprogtable); + sql = FormatInvariant($@"CREATE TEMPORARY TRIGGER ""UpdateTotalStats_{m_fileprogtable}"" AFTER UPDATE ON ""{m_fileprogtable}"" +BEGIN UPDATE ""{m_totalprogtable}"" + SET ""FilesFullyRestored"" = ""{m_totalprogtable}"".""FilesFullyRestored"" + + (CASE WHEN NEW.""BlocksRestored"" = NEW.""TotalBlocks"" THEN 1 ELSE 0 END) + - (CASE WHEN OLD.""BlocksRestored"" = OLD.""TotalBlocks"" THEN 1 ELSE 0 END) + , ""FilesPartiallyRestored"" = ""{m_totalprogtable}"".""FilesPartiallyRestored"" + + (CASE WHEN NEW.""BlocksRestored"" BETWEEN 1 AND NEW.""TotalBlocks"" - 1 THEN 1 ELSE 0 END) + - (CASE WHEN OLD.""BlocksRestored"" BETWEEN 1 AND OLD.""TotalBlocks"" - 1 THEN 1 ELSE 0 END) + , ""BlocksRestored"" = ""{m_totalprogtable}"".""BlocksRestored"" + NEW.""BlocksRestored"" - OLD.""BlocksRestored"" + , ""SizeRestored"" = ""{m_totalprogtable}"".""SizeRestored"" + NEW.""SizeRestored"" - OLD.""SizeRestored"" +; END"); cmd.ExecuteNonQuery(sql); if (createFilesNewlyDoneTracker) { // A trigger checking if a file is done (all blocks restored in file-stat) (nested triggering by file-stats) - sql = string.Format( - @"CREATE TEMPORARY TRIGGER ""UpdateFilesNewlyDone_{1}"" AFTER UPDATE OF ""BlocksRestored"", ""TotalBlocks"" ON ""{1}"" " - + @" WHEN NEW.""BlocksRestored"" = NEW.""TotalBlocks"" " - + @" BEGIN " - + @" INSERT OR IGNORE INTO ""{0}"" (""ID"") VALUES (NEW.""FileId""); " - + @" END " - , m_filesnewlydonetable, m_fileprogtable); + sql = FormatInvariant($@"CREATE TEMPORARY TRIGGER ""UpdateFilesNewlyDone_{m_fileprogtable}"" AFTER UPDATE OF ""BlocksRestored"", ""TotalBlocks"" ON ""{m_fileprogtable}"" +WHEN NEW.""BlocksRestored"" = NEW.""TotalBlocks"" +BEGIN + INSERT OR IGNORE INTO ""{m_filesnewlydonetable}"" (""ID"") VALUES (NEW.""FileId""); +END "); cmd.ExecuteNonQuery(sql); } @@ -206,7 +199,7 @@ namespace Duplicati.Library.Main.Database } - public Tuple PrepareRestoreFilelist(DateTime restoretime, long[] versions, Library.Utility.IFilter filter) + public Tuple PrepareRestoreFilelist(DateTime restoretime, long[] versions, IFilter filter) { m_tempfiletable = "Fileset-" + m_temptabsetguid; m_tempblocktable = "Blocks-" + m_temptabsetguid; @@ -221,7 +214,7 @@ namespace Duplicati.Library.Main.Database m_restoreTime = ParseFromEpochSeconds(cmd.ExecuteScalarInt64(@"SELECT ""Timestamp"" FROM ""Fileset"" WHERE ""ID"" = ?", 0, filesetId)); - var ix = this.FilesetTimes.Select((value, index) => new { value.Key, index }) + var ix = FilesetTimes.Select((value, index) => new { value.Key, index }) .Where(n => n.Key == filesetId) .Select(pair => pair.index + 1) .FirstOrDefault() - 1; @@ -230,17 +223,17 @@ namespace Duplicati.Library.Main.Database cmd.Parameters.Clear(); - cmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"" ", m_tempfiletable)); - cmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"" ", m_tempblocktable)); - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" (""ID"" INTEGER PRIMARY KEY, ""Path"" TEXT NOT NULL, ""BlocksetID"" INTEGER NOT NULL, ""MetadataID"" INTEGER NOT NULL, ""TargetPath"" TEXT NULL, ""DataVerified"" BOOLEAN NOT NULL, ""LatestBlocksetId"" INTEGER, ""LocalSourceExists"" BOOLEAN) ", m_tempfiletable)); - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" (""ID"" INTEGER PRIMARY KEY, ""FileID"" INTEGER NOT NULL, ""Index"" INTEGER NOT NULL, ""Hash"" TEXT NOT NULL, ""Size"" INTEGER NOT NULL, ""Restored"" BOOLEAN NOT NULL, ""Metadata"" BOOLEAN NOT NULL, ""VolumeID"" INTEGER NOT NULL, ""BlockID"" INTEGER NOT NULL)", m_tempblocktable)); + cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{m_tempfiletable}"" ")); + cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{m_tempblocktable}"" ")); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{m_tempfiletable}"" (""ID"" INTEGER PRIMARY KEY, ""Path"" TEXT NOT NULL, ""BlocksetID"" INTEGER NOT NULL, ""MetadataID"" INTEGER NOT NULL, ""TargetPath"" TEXT NULL, ""DataVerified"" BOOLEAN NOT NULL, ""LatestBlocksetId"" INTEGER, ""LocalSourceExists"" BOOLEAN) ")); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{m_tempblocktable}"" (""ID"" INTEGER PRIMARY KEY, ""FileID"" INTEGER NOT NULL, ""Index"" INTEGER NOT NULL, ""Hash"" TEXT NOT NULL, ""Size"" INTEGER NOT NULL, ""Restored"" BOOLEAN NOT NULL, ""Metadata"" BOOLEAN NOT NULL, ""VolumeID"" INTEGER NOT NULL, ""BlockID"" INTEGER NOT NULL)")); // TODO: Optimize to use the path prefix if (filter == null || filter.Empty) { // Simple case, restore everything - cmd.CommandText = string.Format(@"INSERT INTO ""{0}"" (""Path"", ""BlocksetID"", ""MetadataID"", ""DataVerified"") SELECT ""File"".""Path"", ""File"".""BlocksetID"", ""File"".""MetadataID"", 0 FROM ""File"", ""FilesetEntry"" WHERE ""File"".""ID"" = ""FilesetEntry"".""FileID"" AND ""FilesetEntry"".""FilesetID"" = ? ", m_tempfiletable); + cmd.CommandText = FormatInvariant($@"INSERT INTO ""{m_tempfiletable}"" (""ID"", ""Path"", ""BlocksetID"", ""MetadataID"", ""DataVerified"") SELECT ""File"".""ID"", ""File"".""Path"", ""File"".""BlocksetID"", ""File"".""MetadataID"", 0 FROM ""File"", ""FilesetEntry"" WHERE ""File"".""ID"" = ""FilesetEntry"".""FileID"" AND ""FilesetEntry"".""FilesetID"" = ? "); cmd.AddParameter(filesetId); cmd.ExecuteNonQuery(); } @@ -254,8 +247,8 @@ namespace Duplicati.Library.Main.Database var p = expression.GetSimpleList(); var m_filenamestable = "Filenames-" + m_temptabsetguid; cmd.Transaction = tr; - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" (""Path"" TEXT NOT NULL) ", m_filenamestable)); - cmd.CommandText = string.Format(@"INSERT INTO ""{0}"" (""Path"") VALUES (?)", m_filenamestable); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{m_filenamestable}"" (""Path"" TEXT NOT NULL) ")); + cmd.CommandText = FormatInvariant($@"INSERT INTO ""{m_filenamestable}"" (""Path"") VALUES (?)"); cmd.AddParameter(); foreach (var s in p) @@ -264,7 +257,7 @@ namespace Duplicati.Library.Main.Database cmd.ExecuteNonQuery(); } - cmd.CommandText = string.Format(@"INSERT INTO ""{0}"" (""Path"", ""BlocksetID"", ""MetadataID"", ""DataVerified"") SELECT ""File"".""Path"", ""File"".""BlocksetID"", ""File"".""MetadataID"", 0 FROM ""File"", ""FilesetEntry"" WHERE ""File"".""ID"" = ""FilesetEntry"".""FileID"" AND ""FilesetEntry"".""FilesetID"" = ? AND ""Path"" IN (SELECT DISTINCT ""Path"" FROM ""{1}"") ", m_tempfiletable, m_filenamestable); + cmd.CommandText = FormatInvariant($@"INSERT INTO ""{m_tempfiletable}"" (""ID"", ""Path"", ""BlocksetID"", ""MetadataID"", ""DataVerified"") SELECT ""File"".""ID"", ""File"".""Path"", ""File"".""BlocksetID"", ""File"".""MetadataID"", 0 FROM ""File"", ""FilesetEntry"" WHERE ""File"".""ID"" = ""FilesetEntry"".""FileID"" AND ""FilesetEntry"".""FilesetID"" = ? AND ""Path"" IN (SELECT DISTINCT ""Path"" FROM ""{m_filenamestable}"") "); cmd.SetParameterValue(0, filesetId); var c = cmd.ExecuteNonQuery(); @@ -275,7 +268,7 @@ namespace Duplicati.Library.Main.Database var sb = new StringBuilder(); sb.AppendLine(); - using (var rd = cmd.ExecuteReader(string.Format(@"SELECT ""Path"" FROM ""{0}"" WHERE ""Path"" NOT IN (SELECT ""Path"" FROM ""{1}"")", m_filenamestable, m_tempfiletable))) + using (var rd = cmd.ExecuteReader(FormatInvariant($@"SELECT ""Path"" FROM ""{m_filenamestable}"" WHERE ""Path"" NOT IN (SELECT ""Path"" FROM ""{m_tempfiletable}"")"))) while (rd.Read()) sb.AppendLine(rd.GetValue(0).ToString()); @@ -284,7 +277,7 @@ namespace Duplicati.Library.Main.Database cmd.Parameters.Clear(); } - cmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"" ", m_filenamestable)); + cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{m_filenamestable}"" ")); using (new Logging.Timer(LOGTAG, "CommitPrepareFileset", "CommitPrepareFileset")) tr.Commit(); @@ -294,37 +287,32 @@ namespace Duplicati.Library.Main.Database { // Restore but filter elements based on the filter expression // If this is too slow, we could add a special handler for wildcard searches too - cmd.CommandText = @"SELECT ""File"".""Path"", ""File"".""BlocksetID"", ""File"".""MetadataID"" FROM ""File"", ""FilesetEntry"" WHERE ""File"".""ID"" = ""FilesetEntry"".""FileID"" AND ""FilesetID"" = ?"; + cmd.CommandText = @"SELECT ""File"".""ID"", ""File"".""Path"", ""File"".""BlocksetID"", ""File"".""MetadataID"" FROM ""File"", ""FilesetEntry"" WHERE ""File"".""ID"" = ""FilesetEntry"".""FileID"" AND ""FilesetID"" = ?"; cmd.AddParameter(filesetId); - object[] values = new object[3]; - using (var cmd2 = m_connection.CreateCommand()) - { - cmd2.CommandText = string.Format(@"INSERT INTO ""{0}"" (""Path"", ""BlocksetID"", ""MetadataID"", ""DataVerified"") VALUES (?,?,?,0)", m_tempfiletable); - cmd2.AddParameter(); - cmd2.AddParameter(); - cmd2.AddParameter(); - - using (var rd = cmd.ExecuteReader()) - while (rd.Read()) + object[] values = new object[4]; + using (var cmd2 = m_connection.CreateCommand(FormatInvariant($@"INSERT INTO ""{m_tempfiletable}"" (""ID"", ""Path"", ""BlocksetID"", ""MetadataID"", ""DataVerified"") VALUES (?,?,?,?,0)"))) + using (var rd = cmd.ExecuteReader()) + while (rd.Read()) + { + rd.GetValues(values); + if (values[1] != null && values[1] != DBNull.Value && Library.Utility.FilterExpression.Matches(filter, values[1].ToString())) { - rd.GetValues(values); - if (values[0] != null && values[0] != DBNull.Value && Library.Utility.FilterExpression.Matches(filter, values[0].ToString())) - { - cmd2.SetParameterValue(0, values[0]); - cmd2.SetParameterValue(1, values[1]); - cmd2.SetParameterValue(2, values[2]); - cmd2.ExecuteNonQuery(); - } + cmd2.SetParameterValue(0, values[0]); + cmd2.SetParameterValue(1, values[1]); + cmd2.SetParameterValue(2, values[2]); + cmd2.SetParameterValue(3, values[3]); + cmd2.ExecuteNonQuery(); } - } + } } //creating indexes after insertion is much faster - cmd.ExecuteNonQuery(string.Format(@"CREATE INDEX ""{0}_TargetPath"" ON ""{0}"" (""TargetPath"")", m_tempfiletable)); - cmd.ExecuteNonQuery(string.Format(@"CREATE INDEX ""{0}_Path"" ON ""{0}"" (""Path"")", m_tempfiletable)); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE INDEX ""{m_tempfiletable}_ID"" ON ""{m_tempfiletable}"" (""ID"")")); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE INDEX ""{m_tempfiletable}_TargetPath"" ON ""{m_tempfiletable}"" (""TargetPath"")")); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE INDEX ""{m_tempfiletable}_Path"" ON ""{m_tempfiletable}"" (""Path"")")); - using (var rd = cmd.ExecuteReader(string.Format(@"SELECT COUNT(DISTINCT ""{0}"".""Path""), SUM(""Blockset"".""Length"") FROM ""{0}"", ""Blockset"" WHERE ""{0}"".""BlocksetID"" = ""Blockset"".""ID"" ", m_tempfiletable))) + using (var rd = cmd.ExecuteReader(FormatInvariant($@"SELECT COUNT(DISTINCT ""{m_tempfiletable}"".""Path""), SUM(""Blockset"".""Length"") FROM ""{m_tempfiletable}"", ""Blockset"" WHERE ""{m_tempfiletable}"".""BlocksetID"" = ""Blockset"".""ID"" "))) { var filecount = 0L; var filesize = 0L; @@ -351,7 +339,7 @@ namespace Duplicati.Library.Main.Database { using (var cmd = m_connection.CreateCommand()) { - cmd.CommandText = string.Format(@"SELECT ""Path"" FROM ""{0}"" ORDER BY LENGTH(""Path"") DESC LIMIT 1", m_tempfiletable); + cmd.CommandText = FormatInvariant($@"SELECT ""Path"" FROM ""{m_tempfiletable}"" ORDER BY LENGTH(""Path"") DESC LIMIT 1"); var v0 = cmd.ExecuteScalar(); if (v0 == null || v0 == DBNull.Value) return null; @@ -364,20 +352,20 @@ namespace Duplicati.Library.Main.Database { using (var cmd = m_connection.CreateCommand()) { - cmd.CommandText = string.Format(@"SELECT ""Path"" FROM ""{0}"" ORDER BY LENGTH(""Path"") DESC LIMIT 1", m_tempfiletable); + cmd.CommandText = FormatInvariant($@"SELECT ""Path"" FROM ""{m_tempfiletable}"" ORDER BY LENGTH(""Path"") DESC LIMIT 1"); var v0 = cmd.ExecuteScalar(); - string maxpath = ""; + var maxpath = ""; if (v0 != null && v0 != DBNull.Value) maxpath = v0.ToString(); var dirsep = Util.GuessDirSeparator(maxpath); - cmd.CommandText = string.Format(@"SELECT COUNT(*) FROM ""{0}""", m_tempfiletable); + cmd.CommandText = FormatInvariant($@"SELECT COUNT(*) FROM ""{m_tempfiletable}"""); var filecount = cmd.ExecuteScalarInt64(-1); - long foundfiles = -1; + var foundfiles = -1L; //TODO: Handle FS case-sensitive? - cmd.CommandText = string.Format(@"SELECT COUNT(*) FROM ""{0}"" WHERE SUBSTR(""Path"", 1, ?) = ?", m_tempfiletable); + cmd.CommandText = FormatInvariant($@"SELECT COUNT(*) FROM ""{m_tempfiletable}"" WHERE SUBSTR(""Path"", 1, ?) = ?"); cmd.AddParameter(); cmd.AddParameter(); @@ -418,19 +406,19 @@ namespace Duplicati.Library.Main.Database if ((OperatingSystem.IsMacOS() || OperatingSystem.IsLinux()) && dirsep == "\\") { // For Win -> Linux, we remove the colon from the drive letter, and use the drive letter as root folder - cmd.ExecuteNonQuery(string.Format(@"UPDATE ""{0}"" SET ""Targetpath"" = CASE WHEN SUBSTR(""Path"", 2, 1) == ':' THEN '\\' || SUBSTR(""Path"", 1, 1) || SUBSTR(""Path"", 3) ELSE ""Path"" END", m_tempfiletable)); - cmd.ExecuteNonQuery(string.Format(@"UPDATE ""{0}"" SET ""Targetpath"" = CASE WHEN SUBSTR(""Path"", 1, 2) == '\\' THEN '\\' || SUBSTR(""Path"", 2) ELSE ""Path"" END", m_tempfiletable)); + cmd.ExecuteNonQuery(FormatInvariant($@"UPDATE ""{m_tempfiletable}"" SET ""Targetpath"" = CASE WHEN SUBSTR(""Path"", 2, 1) == ':' THEN '\\' || SUBSTR(""Path"", 1, 1) || SUBSTR(""Path"", 3) ELSE ""Path"" END")); + cmd.ExecuteNonQuery(FormatInvariant($@"UPDATE ""{m_tempfiletable}"" SET ""Targetpath"" = CASE WHEN SUBSTR(""Path"", 1, 2) == '\\' THEN '\\' || SUBSTR(""Path"", 2) ELSE ""Path"" END")); } else if (OperatingSystem.IsWindows() && dirsep == "/") { // For Linux -> Win, we use the temporary folder's drive as the root path - cmd.ExecuteNonQuery(string.Format(@"UPDATE ""{0}"" SET ""Targetpath"" = CASE WHEN SUBSTR(""Path"", 1, 1) == '/' THEN ? || SUBSTR(""Path"", 2) ELSE ""Path"" END", m_tempfiletable), Util.AppendDirSeparator(System.IO.Path.GetPathRoot(Library.Utility.TempFolder.SystemTempPath)).Replace("\\", "/")); + cmd.ExecuteNonQuery(FormatInvariant($@"UPDATE ""{m_tempfiletable}"" SET ""Targetpath"" = CASE WHEN SUBSTR(""Path"", 1, 1) == '/' THEN ? || SUBSTR(""Path"", 2) ELSE ""Path"" END"), Util.AppendDirSeparator(System.IO.Path.GetPathRoot(Library.Utility.TempFolder.SystemTempPath)).Replace("\\", "/")); } else { // Same OS, just use the path directly - cmd.ExecuteNonQuery(string.Format(@"UPDATE ""{0}"" SET ""Targetpath"" = ""Path"" ", m_tempfiletable)); + cmd.ExecuteNonQuery(FormatInvariant($@"UPDATE ""{m_tempfiletable}"" SET ""Targetpath"" = ""Path"" ")); } } else @@ -440,32 +428,32 @@ namespace Duplicati.Library.Main.Database //Special case, restoring to new folder, but files are from different drives (no shared root on Windows) // We use the format / / - cmd.ExecuteNonQuery(string.Format(@"UPDATE ""{0}"" SET ""TargetPath"" = CASE WHEN SUBSTR(""Path"", 2, 1) == ':' THEN SUBSTR(""Path"", 1, 1) || SUBSTR(""Path"", 3) ELSE ""Path"" END", m_tempfiletable)); + cmd.ExecuteNonQuery(FormatInvariant($@"UPDATE ""{m_tempfiletable}"" SET ""TargetPath"" = CASE WHEN SUBSTR(""Path"", 2, 1) == ':' THEN SUBSTR(""Path"", 1, 1) || SUBSTR(""Path"", 3) ELSE ""Path"" END")); // For UNC paths, we use \\server\folder -> / / - cmd.ExecuteNonQuery(string.Format(@"UPDATE ""{0}"" SET ""TargetPath"" = CASE WHEN SUBSTR(""Path"", 1, 2) == '\\' THEN SUBSTR(""Path"", 2) ELSE ""TargetPath"" END", m_tempfiletable)); + cmd.ExecuteNonQuery(FormatInvariant($@"UPDATE ""{m_tempfiletable}"" SET ""TargetPath"" = CASE WHEN SUBSTR(""Path"", 1, 2) == '\\' THEN SUBSTR(""Path"", 2) ELSE ""TargetPath"" END")); } else { largest_prefix = Util.AppendDirSeparator(largest_prefix, dirsep); - cmd.ExecuteNonQuery(string.Format(@"UPDATE ""{0}"" SET ""TargetPath"" = SUBSTR(""Path"", ?)", m_tempfiletable), largest_prefix.Length + 1); + cmd.ExecuteNonQuery(FormatInvariant($@"UPDATE ""{m_tempfiletable}"" SET ""TargetPath"" = SUBSTR(""Path"", ?)"), largest_prefix.Length + 1); } } // Cross-os path remapping support if ((OperatingSystem.IsMacOS() || OperatingSystem.IsLinux()) && dirsep == "\\") // For Win paths on Linux - cmd.ExecuteNonQuery(string.Format(@"UPDATE ""{0}"" SET ""TargetPath"" = REPLACE(""TargetPath"", '\', '/')", m_tempfiletable)); + cmd.ExecuteNonQuery(FormatInvariant($@"UPDATE ""{m_tempfiletable}"" SET ""TargetPath"" = REPLACE(""TargetPath"", '\', '/')")); else if (OperatingSystem.IsWindows() && dirsep == "/") // For Linux paths on Windows - cmd.ExecuteNonQuery(string.Format(@"UPDATE ""{0}"" SET ""TargetPath"" = REPLACE(REPLACE(""TargetPath"", '\', '_'), '/', '\')", m_tempfiletable)); + cmd.ExecuteNonQuery(FormatInvariant($@"UPDATE ""{m_tempfiletable}"" SET ""TargetPath"" = REPLACE(REPLACE(""TargetPath"", '\', '_'), '/', '\')")); if (!string.IsNullOrEmpty(destination)) { // Paths are now relative with target-os naming system // so we prefix them with the target path - cmd.ExecuteNonQuery(string.Format(@"UPDATE ""{0}"" SET ""TargetPath"" = ? || ""TargetPath"" ", m_tempfiletable), Util.AppendDirSeparator(destination)); + cmd.ExecuteNonQuery(FormatInvariant($@"UPDATE ""{m_tempfiletable}"" SET ""TargetPath"" = ? || ""TargetPath"" "), Util.AppendDirSeparator(destination)); } @@ -476,23 +464,23 @@ namespace Duplicati.Library.Main.Database { using (var cmd = m_connection.CreateCommand()) { - cmd.CommandText = string.Format(@"INSERT INTO ""{0}"" (""FileID"", ""Index"", ""Hash"", ""Size"", ""Restored"", ""Metadata"", ""VolumeId"", ""BlockId"") SELECT DISTINCT ""{1}"".""ID"", ""BlocksetEntry"".""Index"", ""Block"".""Hash"", ""Block"".""Size"", 0, 0, ""Block"".""VolumeID"", ""Block"".""ID"" FROM ""{1}"", ""BlocksetEntry"", ""Block"" WHERE ""{1}"".""BlocksetID"" = ""BlocksetEntry"".""BlocksetID"" AND ""BlocksetEntry"".""BlockID"" = ""Block"".""ID"" ", m_tempblocktable, m_tempfiletable); + cmd.CommandText = FormatInvariant($@"INSERT INTO ""{m_tempblocktable}"" (""FileID"", ""Index"", ""Hash"", ""Size"", ""Restored"", ""Metadata"", ""VolumeId"", ""BlockId"") SELECT DISTINCT ""{m_tempfiletable}"".""ID"", ""BlocksetEntry"".""Index"", ""Block"".""Hash"", ""Block"".""Size"", 0, 0, ""Block"".""VolumeID"", ""Block"".""ID"" FROM ""{m_tempfiletable}"", ""BlocksetEntry"", ""Block"" WHERE ""{m_tempfiletable}"".""BlocksetID"" = ""BlocksetEntry"".""BlocksetID"" AND ""BlocksetEntry"".""BlockID"" = ""Block"".""ID"" "); var p1 = cmd.ExecuteNonQuery(); - int p2 = 0; + var p2 = 0; if (!skipMetadata) { - cmd.CommandText = string.Format(@"INSERT INTO ""{0}"" (""FileID"", ""Index"", ""Hash"", ""Size"", ""Restored"", ""Metadata"", ""VolumeId"", ""BlockId"") SELECT DISTINCT ""{1}"".""ID"", ""BlocksetEntry"".""Index"", ""Block"".""Hash"", ""Block"".""Size"", 0, 1, ""Block"".""VolumeID"", ""Block"".""ID"" FROM ""{1}"", ""BlocksetEntry"", ""Block"", ""Metadataset"" WHERE ""{1}"".""MetadataID"" = ""Metadataset"".""ID"" AND ""Metadataset"".""BlocksetID"" = ""BlocksetEntry"".""BlocksetID"" AND ""BlocksetEntry"".""BlockID"" = ""Block"".""ID"" ", m_tempblocktable, m_tempfiletable); + cmd.CommandText = FormatInvariant($@"INSERT INTO ""{m_tempblocktable}"" (""FileID"", ""Index"", ""Hash"", ""Size"", ""Restored"", ""Metadata"", ""VolumeId"", ""BlockId"") SELECT DISTINCT ""{m_tempfiletable}"".""ID"", ""BlocksetEntry"".""Index"", ""Block"".""Hash"", ""Block"".""Size"", 0, 1, ""Block"".""VolumeID"", ""Block"".""ID"" FROM ""{m_tempfiletable}"", ""BlocksetEntry"", ""Block"", ""Metadataset"" WHERE ""{m_tempfiletable}"".""MetadataID"" = ""Metadataset"".""ID"" AND ""Metadataset"".""BlocksetID"" = ""BlocksetEntry"".""BlocksetID"" AND ""BlocksetEntry"".""BlockID"" = ""Block"".""ID"" "); p2 = cmd.ExecuteNonQuery(); } //creating indexes after insertion is much faster - cmd.ExecuteNonQuery(string.Format(@"CREATE INDEX ""{0}_HashSizeIndex"" ON ""{0}"" (""Hash"", ""Size"")", m_tempblocktable)); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE INDEX ""{m_tempblocktable}_HashSizeIndex"" ON ""{m_tempblocktable}"" (""Hash"", ""Size"")")); // better suited to speed up commit on UpdateBlocks - cmd.ExecuteNonQuery(string.Format(@"CREATE INDEX ""{0}_FileIdIndexIndex"" ON ""{0}"" (""FileId"", ""Index"")", m_tempblocktable)); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE INDEX ""{m_tempblocktable}_FileIdIndexIndex"" ON ""{m_tempblocktable}"" (""FileId"", ""Index"")")); - var size = cmd.ExecuteScalarInt64(string.Format(@"SELECT SUM(""Size"") FROM ""{0}"" ", m_tempblocktable), 0); + var size = cmd.ExecuteScalarInt64(FormatInvariant($@"SELECT SUM(""Size"") FROM ""{m_tempblocktable}"" "), 0); Logging.Log.WriteVerboseMessage(LOGTAG, "RestoreSourceSize", "Restore list contains {0} blocks with a total size of {1}", p1 + p2, Library.Utility.Utility.FormatSizeString(size)); } } @@ -500,7 +488,7 @@ namespace Duplicati.Library.Main.Database public void UpdateTargetPath(long ID, string newname) { using (var cmd = m_connection.CreateCommand()) - cmd.ExecuteNonQuery(string.Format(@"UPDATE ""{0}"" SET ""TargetPath"" = ? WHERE ""ID"" = ?", m_tempfiletable), newname, ID); + cmd.ExecuteNonQuery(FormatInvariant($@"UPDATE ""{m_tempfiletable}"" SET ""TargetPath"" = ? WHERE ""ID"" = ?"), newname, ID); } public interface IExistingFileBlock @@ -570,9 +558,9 @@ namespace Duplicati.Library.Main.Database private class ExistingFile : IExistingFile { - private readonly System.Data.IDataReader m_reader; + private readonly IDataReader m_reader; - public ExistingFile(System.Data.IDataReader rd) { m_reader = rd; HasMore = true; } + public ExistingFile(IDataReader rd) { m_reader = rd; HasMore = true; } public string TargetPath { get { return m_reader.ConvertValueToString(0); } } public string TargetHash { get { return m_reader.ConvertValueToString(1); } } @@ -583,9 +571,9 @@ namespace Duplicati.Library.Main.Database private class ExistingFileBlock : IExistingFileBlock { - private readonly System.Data.IDataReader m_reader; + private readonly IDataReader m_reader; - public ExistingFileBlock(System.Data.IDataReader rd) { m_reader = rd; } + public ExistingFileBlock(IDataReader rd) { m_reader = rd; } public string Hash { get { return m_reader.ConvertValueToString(4); } } public long Index { get { return m_reader.ConvertValueToInt64(5); } } @@ -596,8 +584,8 @@ namespace Duplicati.Library.Main.Database { get { - string p = this.TargetPath; - while (HasMore && p == this.TargetPath) + string p = TargetPath; + while (HasMore && p == TargetPath) { yield return new ExistingFileBlock(m_reader); HasMore = m_reader.Read(); @@ -605,11 +593,11 @@ namespace Duplicati.Library.Main.Database } } - public static IEnumerable GetExistingFilesWithBlocks(System.Data.IDbConnection connection, string tablename) + public static IEnumerable GetExistingFilesWithBlocks(IDbConnection connection, string tablename) { using (var cmd = connection.CreateCommand()) { - cmd.CommandText = string.Format(@"SELECT ""{0}"".""TargetPath"", ""Blockset"".""FullHash"", ""{0}"".""ID"", ""Blockset"".""Length"", ""Block"".""Hash"", ""BlocksetEntry"".""Index"", ""Block"".""Size"" FROM ""{0}"", ""Blockset"", ""BlocksetEntry"", ""Block"" WHERE ""{0}"".""BlocksetID"" = ""Blockset"".""ID"" AND ""BlocksetEntry"".""BlocksetID"" = ""{0}"".""BlocksetID"" AND ""BlocksetEntry"".""BlockID"" = ""Block"".""ID"" ORDER BY ""{0}"".""TargetPath"", ""BlocksetEntry"".""Index""", tablename); + cmd.CommandText = FormatInvariant($@"SELECT ""{tablename}"".""TargetPath"", ""Blockset"".""FullHash"", ""{tablename}"".""ID"", ""Blockset"".""Length"", ""Block"".""Hash"", ""BlocksetEntry"".""Index"", ""Block"".""Size"" FROM ""{tablename}"", ""Blockset"", ""BlocksetEntry"", ""Block"" WHERE ""{tablename}"".""BlocksetID"" = ""Blockset"".""ID"" AND ""BlocksetEntry"".""BlocksetID"" = ""{tablename}"".""BlocksetID"" AND ""BlocksetEntry"".""BlockID"" = ""Block"".""ID"" ORDER BY ""{tablename}"".""TargetPath"", ""BlocksetEntry"".""Index"""); using (var rd = cmd.ExecuteReader()) if (rd.Read()) { @@ -640,16 +628,16 @@ namespace Duplicati.Library.Main.Database { private class BlockSource : IBlockSource { - private readonly System.Data.IDataReader m_reader; - public BlockSource(System.Data.IDataReader rd) { m_reader = rd; } + private readonly IDataReader m_reader; + public BlockSource(IDataReader rd) { m_reader = rd; } public string Path { get { return m_reader.ConvertValueToString(6); } } public long Offset { get { return m_reader.ConvertValueToInt64(7); } } public bool IsMetadata { get { return false; } } } - private readonly System.Data.IDataReader m_reader; - public BlockDescriptor(System.Data.IDataReader rd) { m_reader = rd; HasMore = true; } + private readonly IDataReader m_reader; + public BlockDescriptor(IDataReader rd) { m_reader = rd; HasMore = true; } private string TargetPath { get { return m_reader.ConvertValueToString(0); } } @@ -665,11 +653,11 @@ namespace Duplicati.Library.Main.Database { get { - var p = this.TargetPath; - var h = this.Hash; - var s = this.Size; + var p = TargetPath; + var h = Hash; + var s = Size; - while (HasMore && p == this.TargetPath && h == this.Hash && s == this.Size) + while (HasMore && p == TargetPath && h == Hash && s == Size) { yield return new BlockSource(m_reader); HasMore = m_reader.Read(); @@ -678,8 +666,8 @@ namespace Duplicati.Library.Main.Database } } - private readonly System.Data.IDataReader m_reader; - public LocalBlockSource(System.Data.IDataReader rd) { m_reader = rd; HasMore = true; } + private readonly IDataReader m_reader; + public LocalBlockSource(IDataReader rd) { m_reader = rd; HasMore = true; } public string TargetPath { get { return m_reader.ConvertValueToString(0); } } public long TargetFileID { get { return m_reader.ConvertValueToInt64(1); } } @@ -690,8 +678,8 @@ namespace Duplicati.Library.Main.Database { get { - var p = this.TargetPath; - while (HasMore && p == this.TargetPath) + var p = TargetPath; + while (HasMore && p == TargetPath) { var c = new BlockDescriptor(m_reader); var h = c.Hash; @@ -700,18 +688,18 @@ namespace Duplicati.Library.Main.Database yield return c; HasMore = c.HasMore; - while (HasMore && c.Hash == h && c.Size == s && this.TargetPath == p) + while (HasMore && c.Hash == h && c.Size == s && TargetPath == p) HasMore = m_reader.Read(); } } } - public static IEnumerable GetFilesAndSourceBlocks(System.Data.IDbConnection connection, string filetablename, string blocktablename, long blocksize, bool skipMetadata) + public static IEnumerable GetFilesAndSourceBlocks(IDbConnection connection, string filetablename, string blocktablename, long blocksize, bool skipMetadata) { using (var cmd = connection.CreateCommand()) { // TODO: Skip metadata as required - cmd.CommandText = string.Format(@"SELECT DISTINCT ""A"".""TargetPath"", ""A"".""ID"", ""B"".""Hash"", (""B"".""Index"" * {2}), ""B"".""Index"", ""B"".""Size"", ""C"".""Path"", (""D"".""Index"" * {2}), ""E"".""Size"", ""B"".""Metadata"" FROM ""{0}"" ""A"", ""{1}"" ""B"", ""File"" ""C"", ""BlocksetEntry"" ""D"", ""Block"" E WHERE ""A"".""ID"" = ""B"".""FileID"" AND ""C"".""BlocksetID"" = ""D"".""BlocksetID"" AND ""D"".""BlockID"" = ""E"".""ID"" AND ""B"".""Hash"" = ""E"".""Hash"" AND ""B"".""Size"" = ""E"".""Size"" AND ""B"".""Restored"" = 0", filetablename, blocktablename, blocksize); + cmd.CommandText = FormatInvariant($@"SELECT DISTINCT ""A"".""TargetPath"", ""A"".""ID"", ""B"".""Hash"", (""B"".""Index"" * {blocksize}), ""B"".""Index"", ""B"".""Size"", ""C"".""Path"", (""D"".""Index"" * {blocksize}), ""E"".""Size"", ""B"".""Metadata"" FROM ""{filetablename}"" ""A"", ""{blocktablename}"" ""B"", ""File"" ""C"", ""BlocksetEntry"" ""D"", ""Block"" E WHERE ""A"".""ID"" = ""B"".""FileID"" AND ""C"".""BlocksetID"" = ""D"".""BlocksetID"" AND ""D"".""BlockID"" = ""E"".""ID"" AND ""B"".""Hash"" = ""E"".""Hash"" AND ""B"".""Size"" = ""E"".""Size"" AND ""B"".""Restored"" = 0"); using (var rd = cmd.ExecuteReader()) { if (rd.Read()) @@ -752,16 +740,14 @@ namespace Duplicati.Library.Main.Database // Now it is likely to restore all files from front to back. Large files will always be done last. // One could also use like the average block number in a volume, that needs to be measured. - cmd.CommandText = string.Format( - @"SELECT ""RV"".""Name"", ""RV"".""Hash"", ""RV"".""Size"", ""BB"".""MaxIndex"" " - + @" FROM ""RemoteVolume"" ""RV"" INNER JOIN " - + @" (SELECT ""TB"".""VolumeID"", MAX(""TB"".""Index"") as ""MaxIndex"" " - + @" FROM ""{0}"" ""TB"" " - + @" WHERE ""TB"".""Restored"" = 0 " - + @" GROUP BY ""TB"".""VolumeID"" " - + @" ) as ""BB"" ON ""RV"".""ID"" = ""BB"".""VolumeID"" " - + @" ORDER BY ""BB"".""MaxIndex"" " - , m_tempblocktable); + cmd.CommandText = FormatInvariant($@"SELECT ""RV"".""Name"", ""RV"".""Hash"", ""RV"".""Size"", ""BB"".""MaxIndex"" +FROM ""RemoteVolume"" ""RV"" INNER JOIN + (SELECT ""TB"".""VolumeID"", MAX(""TB"".""Index"") as ""MaxIndex"" + FROM ""{m_tempblocktable}"" ""TB"" + WHERE ""TB"".""Restored"" = 0 + GROUP BY ""TB"".""VolumeID"" + ) as ""BB"" ON ""RV"".""ID"" = ""BB"".""VolumeID"" +ORDER BY ""BB"".""MaxIndex"" "); using (var rd = cmd.ExecuteReader()) { @@ -792,9 +778,9 @@ namespace Duplicati.Library.Main.Database private readonly string m_blocktablename; private readonly long m_blocksize; - private readonly System.Data.IDbConnection m_connection; + private readonly IDbConnection m_connection; - public FilesAndMetadata(System.Data.IDbConnection connection, string filetablename, string blocktablename, long blocksize, BlockVolumeReader curvolume) + public FilesAndMetadata(IDbConnection connection, string filetablename, string blocktablename, long blocksize, BlockVolumeReader curvolume) { m_filetablename = filetablename; m_blocktablename = blocktablename; @@ -804,11 +790,11 @@ namespace Duplicati.Library.Main.Database using (var c = m_connection.CreateCommand()) { m_tmptable = "VolumeFiles-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); - c.CommandText = string.Format(@"CREATE TEMPORARY TABLE ""{0}"" ( ""Hash"" TEXT NOT NULL, ""Size"" INTEGER NOT NULL )", m_tmptable); + c.CommandText = FormatInvariant($@"CREATE TEMPORARY TABLE ""{m_tmptable}"" ( ""Hash"" TEXT NOT NULL, ""Size"" INTEGER NOT NULL )"); c.ExecuteNonQuery(); - c.CommandText = string.Format(@"INSERT INTO ""{0}"" (""Hash"", ""Size"") VALUES (?,?)", m_tmptable); + c.CommandText = FormatInvariant($@"INSERT INTO ""{m_tmptable}"" (""Hash"", ""Size"") VALUES (?,?)"); c.AddParameters(2); foreach (var s in curvolume.Blocks) { @@ -827,7 +813,7 @@ namespace Duplicati.Library.Main.Database if (m_tmptable != null) using (var c = m_connection.CreateCommand()) { - c.CommandText = string.Format(@"DROP TABLE IF EXISTS ""{0}""", m_tmptable); + c.CommandText = FormatInvariant($@"DROP TABLE IF EXISTS ""{m_tmptable}"""); c.ExecuteNonQuery(); } } @@ -836,16 +822,16 @@ namespace Duplicati.Library.Main.Database { private class PatchBlock : IPatchBlock { - private readonly System.Data.IDataReader m_reader; - public PatchBlock(System.Data.IDataReader rd) { m_reader = rd; } + private readonly IDataReader m_reader; + public PatchBlock(IDataReader rd) { m_reader = rd; } public long Offset { get { return m_reader.ConvertValueToInt64(2); } } public long Size { get { return m_reader.ConvertValueToInt64(3); } } public string Key { get { return m_reader.ConvertValueToString(4); } } } - private readonly System.Data.IDataReader m_reader; - public VolumePatch(System.Data.IDataReader rd) { m_reader = rd; HasMore = true; } + private readonly IDataReader m_reader; + public VolumePatch(IDataReader rd) { m_reader = rd; HasMore = true; } public string Path { get { return m_reader.ConvertValueToString(0); } } public long FileID { get { return m_reader.ConvertValueToInt64(1); } } @@ -855,8 +841,8 @@ namespace Duplicati.Library.Main.Database { get { - string p = this.Path; - while (HasMore && p == this.Path) + string p = Path; + while (HasMore && p == Path) { yield return new PatchBlock(m_reader); HasMore = m_reader.Read(); @@ -872,13 +858,11 @@ namespace Duplicati.Library.Main.Database using (var cmd = m_connection.CreateCommand()) { // The IN-clause with subquery enables SQLite to use indexes better. Three way join (A,B,C) is slow here! - cmd.CommandText = string.Format( - @" SELECT DISTINCT ""A"".""TargetPath"", ""BB"".""FileID"", (""BB"".""Index"" * {3}), ""BB"".""Size"", ""BB"".""Hash"" " - + @" FROM ""{0}"" ""A"", ""{1}"" ""BB"" " - + @" WHERE ""A"".""ID"" = ""BB"".""FileID"" AND ""BB"".""Restored"" = 0 AND ""BB"".""Metadata"" = {4}" - + @" AND ""BB"".""ID"" IN (SELECT ""B"".""ID"" FROM ""{1}"" ""B"", ""{2}"" ""C"" WHERE ""B"".""Hash"" = ""C"".""Hash"" AND ""B"".""Size"" = ""C"".""Size"") " - + @" ORDER BY ""A"".""TargetPath"", ""BB"".""Index""" - , m_filetablename, m_blocktablename, m_tmptable, m_blocksize, "0"); + cmd.CommandText = FormatInvariant($@" SELECT DISTINCT ""A"".""TargetPath"", ""BB"".""FileID"", (""BB"".""Index"" * {m_blocksize}), ""BB"".""Size"", ""BB"".""Hash"" +FROM ""{m_filetablename}"" ""A"", ""{m_blocktablename}"" ""BB"" +WHERE ""A"".""ID"" = ""BB"".""FileID"" AND ""BB"".""Restored"" = 0 AND ""BB"".""Metadata"" = {"0"} +AND ""BB"".""ID"" IN (SELECT ""B"".""ID"" FROM ""{m_blocktablename}"" ""B"", ""{m_tmptable}"" ""C"" WHERE ""B"".""Hash"" = ""C"".""Hash"" AND ""B"".""Size"" = ""C"".""Size"") +ORDER BY ""A"".""TargetPath"", ""BB"".""Index"""); using (var rd = cmd.ExecuteReader()) { if (rd.Read()) @@ -887,7 +871,7 @@ namespace Duplicati.Library.Main.Database while (more) { var f = new VolumePatch(rd); - string current = f.Path; + var current = f.Path; yield return f; more = f.HasMore; @@ -907,13 +891,11 @@ namespace Duplicati.Library.Main.Database using (var cmd = m_connection.CreateCommand()) { // The IN-clause with subquery enables SQLite to use indexes better. Three way join (A,B,C) is slow here! - cmd.CommandText = string.Format( - @" SELECT DISTINCT ""A"".""TargetPath"", ""BB"".""FileID"", (""BB"".""Index"" * {3}), ""BB"".""Size"", ""BB"".""Hash"" " - + @" FROM ""{0}"" ""A"", ""{1}"" ""BB"" " - + @" WHERE ""A"".""ID"" = ""BB"".""FileID"" AND ""BB"".""Restored"" = 0 AND ""BB"".""Metadata"" = {4}" - + @" AND ""BB"".""ID"" IN (SELECT ""B"".""ID"" FROM ""{1}"" ""B"", ""{2}"" ""C"" WHERE ""B"".""Hash"" = ""C"".""Hash"" AND ""B"".""Size"" = ""C"".""Size"") " - + @" ORDER BY ""A"".""TargetPath"", ""BB"".""Index""" - , m_filetablename, m_blocktablename, m_tmptable, m_blocksize, "1"); + cmd.CommandText = FormatInvariant($@" SELECT DISTINCT ""A"".""TargetPath"", ""BB"".""FileID"", (""BB"".""Index"" * {m_blocksize}), ""BB"".""Size"", ""BB"".""Hash"" + FROM ""{m_filetablename}"" ""A"", ""{m_blocktablename}"" ""BB"" +WHERE ""A"".""ID"" = ""BB"".""FileID"" AND ""BB"".""Restored"" = 0 AND ""BB"".""Metadata"" = {"1"} + AND ""BB"".""ID"" IN (SELECT ""B"".""ID"" FROM ""{m_blocktablename}"" ""B"", ""{m_tmptable}"" ""C"" WHERE ""B"".""Hash"" = ""C"".""Hash"" AND ""B"".""Size"" = ""C"".""Size"") +ORDER BY ""A"".""TargetPath"", ""BB"".""Index"""); using (var rd = cmd.ExecuteReader()) { if (rd.Read()) @@ -941,6 +923,26 @@ namespace Duplicati.Library.Main.Database return new FilesAndMetadata(m_connection, m_tempfiletable, m_tempblocktable, blocksize, curvolume); } + /// + /// Returns a connection from the connection pool. + /// + /// A connection from the connection pool. + public IDbConnection GetConnectionFromPool() + { + if (!m_connection_pool.TryTake(out var connection)) + { + connection = (IDbConnection)Activator.CreateInstance(Duplicati.Library.SQLiteHelper.SQLiteLoader.SQLiteConnectionType); + connection.ConnectionString = m_connection.ConnectionString + ";Cache=Shared;"; + connection.Open(); + + using var cmd = connection.CreateCommand(); + cmd.ExecuteNonQuery("PRAGMA journal_mode = WAL;"); + cmd.ExecuteNonQuery("PRAGMA read_uncommitted = true;"); + } + + return connection; + } + private class FileToRestore : IFileToRestore { public string Path { get; private set; } @@ -949,9 +951,9 @@ namespace Duplicati.Library.Main.Database public FileToRestore(long id, string path, string hash, long length) { - this.Path = path; - this.Hash = hash; - this.Length = length; + Path = path; + Hash = hash; + Length = length; } } @@ -960,7 +962,7 @@ namespace Duplicati.Library.Main.Database using (var cmd = m_connection.CreateCommand()) { cmd.AddParameter(!onlyNonVerified); - using (var rd = cmd.ExecuteReader(string.Format(@"SELECT ""{0}"".""ID"", ""{0}"".""TargetPath"", ""Blockset"".""FullHash"", ""Blockset"".""Length"" FROM ""{0}"",""Blockset"" WHERE ""{0}"".""BlocksetID"" = ""Blockset"".""ID"" AND ""{0}"".""DataVerified"" <= ?", m_tempfiletable))) + using (var rd = cmd.ExecuteReader(FormatInvariant($@"SELECT ""{m_tempfiletable}"".""ID"", ""{m_tempfiletable}"".""TargetPath"", ""Blockset"".""FullHash"", ""Blockset"".""Length"" FROM ""{m_tempfiletable}"",""Blockset"" WHERE ""{m_tempfiletable}"".""BlocksetID"" = ""Blockset"".""ID"" AND ""{m_tempfiletable}"".""DataVerified"" <= ?"))) while (rd.Read()) yield return new FileToRestore(rd.ConvertValueToInt64(0), rd.ConvertValueToString(1), rd.ConvertValueToString(2), rd.ConvertValueToInt64(3)); } @@ -975,11 +977,11 @@ namespace Duplicati.Library.Main.Database { using var cmd = m_connection.CreateCommand(); cmd.AddParameter(!onlyNonVerified); - using var rd = cmd.ExecuteReader($@" + using var rd = cmd.ExecuteReader(FormatInvariant($@" SELECT F.ID, F.Path, F.TargetPath, IFNULL(B.FullHash, ''), IFNULL(B.Length, 0), F.BlocksetID FROM ""{m_tempfiletable}"" F LEFT JOIN Blockset B ON F.BlocksetID = B.ID - WHERE F.BlocksetID != {FOLDER_BLOCKSET_ID}"); + WHERE F.BlocksetID != {FOLDER_BLOCKSET_ID}")); while (rd.Read()) yield return new FileRequest(rd.ConvertValueToInt64(0), rd.ConvertValueToString(1), rd.ConvertValueToString(2), rd.ConvertValueToString(3), rd.ConvertValueToInt64(4), rd.ConvertValueToInt64(5)); } @@ -992,13 +994,13 @@ namespace Duplicati.Library.Main.Database public IEnumerable<(long, long)> GetBlocksAndVolumeIDs(bool skipMetadata) { using var cmd = Connection.CreateCommand(); - using var reader = cmd.ExecuteReader($@" + using var reader = cmd.ExecuteReader(FormatInvariant($@" SELECT Block.ID, Block.VolumeID FROM BlocksetEntry INNER JOIN ""{m_tempfiletable}"" ON BlocksetEntry.BlocksetID = ""{m_tempfiletable}"".BlocksetID INNER JOIN Block ON BlocksetEntry.BlockID = Block.ID - " - + (skipMetadata ? "" : $@" + ") + + (skipMetadata ? "" : FormatInvariant($@" UNION ALL SELECT Block.ID, Block.VolumeID FROM ""{m_tempfiletable}"" @@ -1006,7 +1008,7 @@ namespace Duplicati.Library.Main.Database INNER JOIN BlocksetEntry ON Metadataset.BlocksetID = BlocksetEntry.BlocksetID INNER JOIN Block ON BlocksetEntry.BlockID = Block.ID WHERE ""{m_tempfiletable}"".BlocksetID IS NOT {FOLDER_BLOCKSET_ID} - ")); + "))); while (reader.Read()) yield return (reader.ConvertValueToInt64(0), reader.ConvertValueToInt64(1)); } @@ -1018,19 +1020,25 @@ namespace Duplicati.Library.Main.Database /// A list of needed to restore the given file. public IEnumerable GetBlocksFromFile(long blocksetID) { - using var cmd = m_connection.CreateCommand(); - cmd.CommandText = @$" + var connection = GetConnectionFromPool(); + using var cmd = connection.CreateCommand(); + + cmd.CommandText = FormatInvariant(@$" SELECT Block.ID, Block.Hash, Block.Size, Block.VolumeID FROM BlocksetEntry INNER JOIN Block ON BlocksetEntry.BlockID = Block.ID - WHERE BlocksetEntry.BlocksetID = ?"; + WHERE BlocksetEntry.BlocksetID = ?"); cmd.AddParameter(); cmd.SetParameterValue(0, blocksetID); + using var reader = cmd.ExecuteReader(); for (long i = 0; reader.Read(); i++) { yield return new BlockRequest(reader.ConvertValueToInt64(0), i, reader.ConvertValueToString(1), reader.ConvertValueToInt64(2), reader.ConvertValueToInt64(3), false); } + + // Return the connection to the pool + m_connection_pool.Add(connection); } /// @@ -1040,22 +1048,28 @@ namespace Duplicati.Library.Main.Database /// A list of needed to restore the metadata of the given file. public IEnumerable GetMetadataBlocksFromFile(long fileID) { - using var cmd = m_connection.CreateCommand(); - cmd.CommandText = $@" + var connection = GetConnectionFromPool(); + using var cmd = connection.CreateCommand(); + + cmd.CommandText = FormatInvariant($@" SELECT Block.ID, Block.Hash, Block.Size, Block.VolumeID - FROM ""{m_tempfiletable}"" - INNER JOIN Metadataset ON ""{m_tempfiletable}"".MetadataID = Metadataset.ID + FROM File + INNER JOIN Metadataset ON File.MetadataID = Metadataset.ID INNER JOIN BlocksetEntry ON Metadataset.BlocksetID = BlocksetEntry.BlocksetID INNER JOIN Block ON BlocksetEntry.BlockID = Block.ID - WHERE ""{m_tempfiletable}"".ID = ? - "; + WHERE File.ID = ? + "); cmd.AddParameter(); cmd.SetParameterValue(0, fileID); + using var reader = cmd.ExecuteReader(); for (long i = 0; reader.Read(); i++) { yield return new BlockRequest(reader.ConvertValueToInt64(0), i, reader.ConvertValueToString(1), reader.ConvertValueToInt64(2), reader.ConvertValueToInt64(3), false); } + + // Return the connection to the pool + m_connection_pool.Add(connection); } /// @@ -1081,7 +1095,7 @@ namespace Duplicati.Library.Main.Database if (m_tempfiletable != null) try { - cmd.CommandText = string.Format(@"DROP TABLE IF EXISTS ""{0}""", m_tempfiletable); + cmd.CommandText = FormatInvariant($@"DROP TABLE IF EXISTS ""{m_tempfiletable}"""); cmd.ExecuteNonQuery(); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "CleanupError", ex, "Cleanup error: {0}", ex.Message); } @@ -1090,7 +1104,7 @@ namespace Duplicati.Library.Main.Database if (m_tempblocktable != null) try { - cmd.CommandText = string.Format(@"DROP TABLE IF EXISTS ""{0}""", m_tempblocktable); + cmd.CommandText = FormatInvariant($@"DROP TABLE IF EXISTS ""{m_tempblocktable}"""); cmd.ExecuteNonQuery(); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "CleanupError", ex, "Cleanup error: {0}", ex.Message); } @@ -1099,7 +1113,7 @@ namespace Duplicati.Library.Main.Database if (m_latestblocktable != null) try { - cmd.CommandText = string.Format(@"DROP TABLE IF EXISTS ""{0}""", m_latestblocktable); + cmd.CommandText = FormatInvariant($@"DROP TABLE IF EXISTS ""{m_latestblocktable}"""); cmd.ExecuteNonQuery(); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "CleanupError", ex, "Cleanup error: {0}", ex.Message); } @@ -1108,7 +1122,7 @@ namespace Duplicati.Library.Main.Database if (m_fileprogtable != null) try { - cmd.CommandText = string.Format(@"DROP TABLE IF EXISTS ""{0}""", m_fileprogtable); + cmd.CommandText = FormatInvariant($@"DROP TABLE IF EXISTS ""{m_fileprogtable}"""); cmd.ExecuteNonQuery(); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "CleanupError", ex, "Cleanup error: {0}", ex.Message); } @@ -1117,7 +1131,7 @@ namespace Duplicati.Library.Main.Database if (m_totalprogtable != null) try { - cmd.CommandText = string.Format(@"DROP TABLE IF EXISTS ""{0}""", m_totalprogtable); + cmd.CommandText = FormatInvariant($@"DROP TABLE IF EXISTS ""{m_totalprogtable}"""); cmd.ExecuteNonQuery(); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "CleanupError", ex, "Cleanup error: {0}", ex.Message); } @@ -1126,7 +1140,7 @@ namespace Duplicati.Library.Main.Database if (m_filesnewlydonetable != null) try { - cmd.CommandText = string.Format(@"DROP TABLE IF EXISTS ""{0}""", m_filesnewlydonetable); + cmd.CommandText = FormatInvariant($@"DROP TABLE IF EXISTS ""{m_filesnewlydonetable}"""); cmd.ExecuteNonQuery(); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "CleanupError", ex, "Cleanup error: {0}", ex.Message); } @@ -1152,62 +1166,44 @@ namespace Duplicati.Library.Main.Database /// private class DirectBlockMarker : IBlockMarker { - private System.Data.IDbCommand m_insertblockCommand; - private System.Data.IDbCommand m_resetfileCommand; - private System.Data.IDbCommand m_updateAsRestoredCommand; - private System.Data.IDbCommand m_updateFileAsDataVerifiedCommand; - private System.Data.IDbCommand m_statUpdateCommand; + private IDbCommand m_insertblockCommand; + private IDbCommand m_resetfileCommand; + private IDbCommand m_updateAsRestoredCommand; + private IDbCommand m_updateFileAsDataVerifiedCommand; + private IDbCommand m_statUpdateCommand; + private IDbTransaction m_transaction; private bool m_hasUpdates = false; private readonly string m_blocktablename; private readonly string m_filetablename; - public DirectBlockMarker(System.Data.IDbConnection connection, string blocktablename, string filetablename, string statstablename) + public DirectBlockMarker(IDbConnection connection, string blocktablename, string filetablename, string statstablename) { - m_insertblockCommand = connection.CreateCommand(); - m_resetfileCommand = connection.CreateCommand(); - m_updateAsRestoredCommand = connection.CreateCommand(); - m_updateFileAsDataVerifiedCommand = connection.CreateCommand(); - m_statUpdateCommand = connection.CreateCommand(); - - m_insertblockCommand.Transaction = connection.BeginTransaction(); - m_resetfileCommand.Transaction = m_insertblockCommand.Transaction; - m_updateAsRestoredCommand.Transaction = m_insertblockCommand.Transaction; - m_updateFileAsDataVerifiedCommand.Transaction = m_insertblockCommand.Transaction; - m_statUpdateCommand.Transaction = m_insertblockCommand.Transaction; - + m_transaction = connection.BeginTransaction(); m_blocktablename = blocktablename; m_filetablename = filetablename; - m_insertblockCommand.CommandText = string.Format( - @"UPDATE ""{0}"" SET ""Restored"" = 1 " - + @" WHERE ""FileID"" = ? AND ""Index"" = ? AND ""Hash"" = ? AND ""Size"" = ? AND ""Metadata"" = ? AND ""Restored"" = 0 " - , m_blocktablename); + m_insertblockCommand = connection.CreateCommand(m_transaction, FormatInvariant($@"UPDATE ""{m_blocktablename}"" SET ""Restored"" = 1 +WHERE ""FileID"" = ? AND ""Index"" = ? AND ""Hash"" = ? AND ""Size"" = ? AND ""Metadata"" = ? AND ""Restored"" = 0 ")); m_insertblockCommand.AddParameters(5); - m_resetfileCommand.CommandText = string.Format( - @"UPDATE ""{0}"" SET ""Restored"" = 0 WHERE ""FileID"" = ? " - , m_blocktablename); + m_resetfileCommand = connection.CreateCommand(m_transaction, FormatInvariant($@"UPDATE ""{m_blocktablename}"" SET ""Restored"" = 0 WHERE ""FileID"" = ? ")); m_resetfileCommand.AddParameters(1); - m_updateAsRestoredCommand.CommandText = string.Format( - @"UPDATE ""{0}"" SET ""Restored"" = 1 WHERE ""FileID"" = ? AND ""Metadata"" <= ? " - , m_blocktablename); + m_updateAsRestoredCommand = connection.CreateCommand(m_transaction, FormatInvariant($@"UPDATE ""{m_blocktablename}"" SET ""Restored"" = 1 WHERE ""FileID"" = ? AND ""Metadata"" <= ? ")); m_updateAsRestoredCommand.AddParameters(2); - m_updateFileAsDataVerifiedCommand.CommandText = string.Format( - @"UPDATE ""{0}"" SET ""DataVerified"" = 1 WHERE ""ID"" = ?" - , m_filetablename); + m_updateFileAsDataVerifiedCommand = connection.CreateCommand(m_transaction, FormatInvariant($@"UPDATE ""{m_filetablename}"" SET ""DataVerified"" = 1 WHERE ""ID"" = ?")); m_updateFileAsDataVerifiedCommand.AddParameters(1); if (statstablename != null) { // Fields in Stats: TotalFiles, TotalBlocks, TotalSize // FilesFullyRestored, FilesPartiallyRestored, BlocksRestored, SizeRestored - m_statUpdateCommand.CommandText = string.Format(@"SELECT SUM(""FilesFullyRestored""), SUM(""SizeRestored"") FROM ""{0}"" ", statstablename); + m_statUpdateCommand = connection.CreateCommand(m_transaction, FormatInvariant($@"SELECT SUM(""FilesFullyRestored""), SUM(""SizeRestored"") FROM ""{statstablename}"" ")); } else // very slow fallback if stats tables were not created - m_statUpdateCommand.CommandText = string.Format(@"SELECT COUNT(DISTINCT ""FileID""), SUM(""Size"") FROM ""{0}"" WHERE ""Restored"" = 1 ", m_blocktablename); + m_statUpdateCommand = connection.CreateCommand(m_transaction, FormatInvariant($@"SELECT COUNT(DISTINCT ""FileID""), SUM(""Size"") FROM ""{m_blocktablename}"" WHERE ""Restored"" = 1 ")); } @@ -1276,40 +1272,22 @@ namespace Duplicati.Library.Main.Database public void Commit() { - var tr = m_insertblockCommand.Transaction; m_insertblockCommand.Dispose(); m_insertblockCommand = null; using (new Logging.Timer(LOGTAG, "CommitBlockMarker", "CommitBlockMarker")) - tr.Commit(); - tr.Dispose(); + m_transaction.Commit(); + m_transaction.Dispose(); + m_transaction = null; } public void Dispose() { - if (m_insertblockCommand != null) - try { m_insertblockCommand.Dispose(); } - catch { } - finally { m_insertblockCommand = null; } - - if (m_resetfileCommand != null) - try { m_resetfileCommand.Dispose(); } - catch { } - finally { m_resetfileCommand = null; } - - if (m_updateAsRestoredCommand != null) - try { m_updateAsRestoredCommand.Dispose(); } - catch { } - finally { m_updateAsRestoredCommand = null; } - - if (m_updateFileAsDataVerifiedCommand != null) - try { m_updateFileAsDataVerifiedCommand.Dispose(); } - catch { } - finally { m_updateFileAsDataVerifiedCommand = null; } - - if (m_statUpdateCommand != null) - try { m_statUpdateCommand.Dispose(); } - catch { } - finally { m_statUpdateCommand = null; } + m_insertblockCommand?.Dispose(); + m_resetfileCommand?.Dispose(); + m_updateAsRestoredCommand?.Dispose(); + m_updateFileAsDataVerifiedCommand?.Dispose(); + m_statUpdateCommand?.Dispose(); + m_transaction?.Dispose(); } } @@ -1320,6 +1298,12 @@ namespace Duplicati.Library.Main.Database public override void Dispose() { + foreach (var connection in m_connection_pool) + { + connection.Close(); + connection.Dispose(); + } + m_connection_pool.Clear(); DropRestoreTable(); base.Dispose(); } @@ -1327,7 +1311,7 @@ namespace Duplicati.Library.Main.Database public IEnumerable GetTargetFolders() { using (var cmd = m_connection.CreateCommand()) - using (var rd = cmd.ExecuteReader(string.Format(@"SELECT ""TargetPath"" FROM ""{0}"" WHERE ""BlocksetID"" == ?", m_tempfiletable), FOLDER_BLOCKSET_ID)) + using (var rd = cmd.ExecuteReader(FormatInvariant($@"SELECT ""TargetPath"" FROM ""{m_tempfiletable}"" WHERE ""BlocksetID"" == ?"), FOLDER_BLOCKSET_ID)) while (rd.Read()) yield return rd.ConvertValueToString(0); } @@ -1352,18 +1336,18 @@ namespace Duplicati.Library.Main.Database { private class BlockEntry : IBlockEntry { - private readonly System.Data.IDataReader m_rd; + private readonly IDataReader m_rd; private readonly long m_blocksize; - public BlockEntry(System.Data.IDataReader rd, long blocksize) { m_rd = rd; m_blocksize = blocksize; } + public BlockEntry(IDataReader rd, long blocksize) { m_rd = rd; m_blocksize = blocksize; } public long Offset { get { return m_rd.ConvertValueToInt64(3) * m_blocksize; } } public long Index { get { return m_rd.ConvertValueToInt64(3); } } public long Size { get { return m_rd.ConvertValueToInt64(5); } } public string Hash { get { return m_rd.ConvertValueToString(4); } } } - private readonly System.Data.IDataReader m_rd; + private readonly IDataReader m_rd; private readonly long m_blocksize; - public FastSource(System.Data.IDataReader rd, long blocksize) { m_rd = rd; m_blocksize = blocksize; MoreData = true; } + public FastSource(IDataReader rd, long blocksize) { m_rd = rd; m_blocksize = blocksize; MoreData = true; } public bool MoreData { get; private set; } public string TargetPath { get { return m_rd.ConvertValueToString(0); } } public long TargetFileID { get { return m_rd.ConvertValueToInt64(2); } } @@ -1373,12 +1357,12 @@ namespace Duplicati.Library.Main.Database { get { - var tid = this.TargetFileID; + var tid = TargetFileID; do { yield return new BlockEntry(m_rd, m_blocksize); - } while ((MoreData = m_rd.Read()) && tid == this.TargetFileID); + } while ((MoreData = m_rd.Read()) && tid == TargetFileID); } } @@ -1387,49 +1371,43 @@ namespace Duplicati.Library.Main.Database public IEnumerable GetFilesAndSourceBlocksFast(long blocksize) { using (var transaction = m_connection.BeginTransaction()) + using (var cmdReader = m_connection.CreateCommand()) + using (var cmd = m_connection.CreateCommand(transaction)) { - using (var cmdReader = m_connection.CreateCommand()) + cmd.CommandText = FormatInvariant($@"UPDATE ""{m_tempfiletable}"" SET ""LocalSourceExists"" = 1 WHERE Path = ?"); + cmd.AddParameters(1); + + var fileset = FormatInvariant($@"SELECT DISTINCT ""{m_tempfiletable}"".""Path"" FROM ""{m_tempfiletable}"""); + using (var rd = cmdReader.ExecuteReader(fileset)) { - using (var cmd = m_connection.CreateCommand()) + while (rd.Read()) { - cmd.CommandText = string.Format(@"UPDATE ""{0}"" SET ""LocalSourceExists"" = 1 WHERE Path = ?", m_tempfiletable); - cmd.AddParameters(1); - cmd.Transaction = transaction; - - var fileset = string.Format(@"SELECT DISTINCT ""{0}"".""Path"" FROM ""{0}""", m_tempfiletable); - using (var rd = cmdReader.ExecuteReader(fileset)) + var sourcepath = rd.GetValue(0).ToString(); + if (SystemIO.IO_OS.FileExists(sourcepath)) { - while (rd.Read()) - { - var sourcepath = rd.GetValue(0).ToString(); - if (SystemIO.IO_OS.FileExists(sourcepath)) - { - cmd.SetParameterValue(0, sourcepath); - cmd.ExecuteNonQuery(); - } - else - { - Logging.Log.WriteVerboseMessage(LOGTAG, "LocalSourceMissing", "Local source file not found: {0}", sourcepath); - } - } + cmd.SetParameterValue(0, sourcepath); + cmd.ExecuteNonQuery(); + } + else + { + Logging.Log.WriteVerboseMessage(LOGTAG, "LocalSourceMissing", "Local source file not found: {0}", sourcepath); } - - //This localSourceExists index will make the query engine to start by searching FileSet table. As the result is ordered by FileSet.ID, we will get the cursor "instantly" - cmd.ExecuteNonQuery(string.Format(@"CREATE INDEX ""{0}_LocalSourceExists"" ON ""{0}"" (""LocalSourceExists"")", m_tempfiletable)); } } + + //This localSourceExists index will make the query engine to start by searching FileSet table. As the result is ordered by FileSet.ID, we will get the cursor "instantly" + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE INDEX ""{m_tempfiletable}_LocalSourceExists"" ON ""{m_tempfiletable}"" (""LocalSourceExists"")")); transaction.Commit(); } m_latestblocktable = "LatestBlocksetIds-" + m_temptabsetguid; - var whereclause = string.Format(@" - ""{0}"".""LocalSourceExists"" = 1 AND - ""{1}"".""Restored"" = 0 AND ""{1}"".""Metadata"" = 0 AND - ""{0}"".""TargetPath"" != ""{0}"".""Path""", - m_tempfiletable, m_tempblocktable); + var whereclause = FormatInvariant($@" + ""{m_tempfiletable}"".""LocalSourceExists"" = 1 AND + ""{m_tempblocktable}"".""Restored"" = 0 AND ""{m_tempblocktable}"".""Metadata"" = 0 AND + ""{m_tempfiletable}"".""TargetPath"" != ""{m_tempfiletable}"".""Path"""); - var latestBlocksetIds = string.Format(@" + var latestBlocksetIds = FormatInvariant($@" SELECT ""File"".""Path"" AS ""PATH"", ""File"".""BlocksetID"" AS ""BlocksetID"", @@ -1443,51 +1421,50 @@ namespace Duplicati.Library.Main.Database ""FilesetEntry"".""FilesetID"" = ""Fileset"".""ID"" AND ""File"".""Path"" IN (SELECT DISTINCT - ""{0}"".""Path"" + ""{m_tempfiletable}"".""Path"" FROM - ""{0}"", - ""{1}"" + ""{m_tempfiletable}"", + ""{m_tempblocktable}"" WHERE - ""{0}"".""ID"" = ""{1}"".""FileID"" AND - {2}) - GROUP BY ""File"".""Path""", - m_tempfiletable, m_tempblocktable, whereclause); + ""{m_tempfiletable}"".""ID"" = ""{m_tempblocktable}"".""FileID"" AND + {whereclause}) + GROUP BY ""File"".""Path"""); using (var cmd = m_connection.CreateCommand()) { - cmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}"" ", m_latestblocktable)); - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" AS {1}", m_latestblocktable, latestBlocksetIds)); - cmd.ExecuteNonQuery(string.Format(@"CREATE INDEX ""{0}_path"" ON ""{0}"" (""Path"")", m_latestblocktable)); + cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{m_latestblocktable}"" ")); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{m_latestblocktable}"" AS {latestBlocksetIds}")); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE INDEX ""{m_latestblocktable}_path"" ON ""{m_latestblocktable}"" (""Path"")")); - cmd.ExecuteNonQuery(string.Format(@"UPDATE ""{0}"" SET LatestBlocksetId = (SELECT BlocksetId FROM ""{1}"" WHERE Path = ""{0}"".Path)", m_tempfiletable, m_latestblocktable)); + cmd.ExecuteNonQuery(FormatInvariant($@"UPDATE ""{m_tempfiletable}"" SET LatestBlocksetId = (SELECT BlocksetId FROM ""{m_latestblocktable}"" WHERE Path = ""{m_tempfiletable}"".Path)")); } - var sources = string.Format( - @" SELECT DISTINCT " + - @" ""{0}"".""TargetPath""," + - @" ""{0}"".""Path""," + - @" ""{0}"".""ID""," + - @" ""{1}"".""Index""," + - @" ""{1}"".""Hash""," + - @" ""{1}"".""Size""" + - @" FROM" + - @" ""{0}""," + - @" ""{1}""," + - @" ""BlocksetEntry""" + - @" WHERE" + - @" ""{0}"".""ID"" = ""{1}"".""FileID"" AND" + - @" ""BlocksetEntry"".""BlocksetID"" = ""{0}"".""LatestBlocksetID"" AND" + - @" ""BlocksetEntry"".""BlockID"" = ""{1}"".""BlockID"" AND" + - @" ""BlocksetEntry"".""Index"" = ""{1}"".""Index"" AND" + - @" " + whereclause + - @" ORDER BY ""{0}"".""ID"", ""{1}"".""Index"" ", m_tempfiletable, m_tempblocktable); + var sources = FormatInvariant($@" +SELECT DISTINCT + ""{m_tempfiletable}"".""TargetPath"", + ""{m_tempfiletable}"".""Path"", + ""{m_tempfiletable}"".""ID"", + ""{m_tempblocktable}"".""Index"", + ""{m_tempblocktable}"".""Hash"", + ""{m_tempblocktable}"".""Size"" +FROM + ""{m_tempfiletable}"", + ""{m_tempblocktable}"", + ""BlocksetEntry"" +WHERE + ""{m_tempfiletable}"".""ID"" = ""{m_tempblocktable}"".""FileID"" AND + ""BlocksetEntry"".""BlocksetID"" = ""{m_tempfiletable}"".""LatestBlocksetID"" AND + ""BlocksetEntry"".""BlockID"" = ""{m_tempblocktable}"".""BlockID"" AND + ""BlocksetEntry"".""Index"" = ""{m_tempblocktable}"".""Index"" AND + {whereclause} +ORDER BY ""{m_tempfiletable}"".""ID"", ""{m_tempblocktable}"".""Index"" "); using (var cmd = m_connection.CreateCommand()) using (var rd = cmd.ExecuteReader(sources)) { if (rd.Read()) { - var more = false; + bool more; do { var n = new FastSource(rd, blocksize); diff --git a/Duplicati/Library/Main/Database/LocalTestDatabase.cs b/Duplicati/Library/Main/Database/LocalTestDatabase.cs index 3b86d9912..6a3f909a7 100644 --- a/Duplicati/Library/Main/Database/LocalTestDatabase.cs +++ b/Duplicati/Library/Main/Database/LocalTestDatabase.cs @@ -20,6 +20,7 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.Data; using System.Linq; using System.Collections.Generic; @@ -51,13 +52,13 @@ namespace Duplicati.Library.Main.Database public string Hash { get; private set; } public long VerificationCount { get; private set; } - public RemoteVolume(System.Data.IDataReader rd) + public RemoteVolume(IDataReader rd) { - this.ID = rd.GetInt64(0); - this.Name = rd.GetString(1); - this.Size = rd.ConvertValueToInt64(2); - this.Hash = rd.ConvertValueToString(3); - this.VerificationCount = rd.ConvertValueToInt64(4); + ID = rd.GetInt64(0); + Name = rd.GetString(1); + Size = rd.ConvertValueToInt64(2); + Hash = rd.ConvertValueToString(3); + VerificationCount = rd.ConvertValueToInt64(4); } } @@ -171,30 +172,26 @@ namespace Duplicati.Library.Main.Database private abstract class Basiclist : IDisposable { - protected readonly System.Data.IDbConnection m_connection; + protected readonly IDbConnection m_connection; protected readonly string m_volumename; protected string m_tablename; - protected System.Data.IDbTransaction m_transaction; - protected System.Data.IDbCommand m_insertCommand; + protected IDbTransaction m_transaction; + protected IDbCommand m_insertCommand; - protected Basiclist(System.Data.IDbConnection connection, string volumename, string tablePrefix, string tableFormat, string insertCommand, int insertArguments) + protected Basiclist(IDbConnection connection, string volumename, string tablePrefix, string tableFormat, string insertCommand) { m_connection = connection; m_volumename = volumename; m_transaction = m_connection.BeginTransaction(); var tablename = tablePrefix + "-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); - using (var cmd = m_connection.CreateCommand()) + using (var cmd = m_connection.CreateCommand(m_transaction)) { - cmd.Transaction = m_transaction; - cmd.ExecuteNonQuery(string.Format(@"CREATE TEMPORARY TABLE ""{0}"" {1}", tablename, tableFormat)); + cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{tablename}"" {tableFormat}")); m_tablename = tablename; } - m_insertCommand = m_connection.CreateCommand(); - m_insertCommand.Transaction = m_transaction; - m_insertCommand.CommandText = string.Format(@"INSERT INTO ""{0}"" {1}", m_tablename, insertCommand); - m_insertCommand.AddParameters(insertArguments); + m_insertCommand = m_connection.CreateCommand(m_transaction, FormatInvariant($@"INSERT INTO ""{m_tablename}"" {insertCommand}")); } public virtual void Dispose() @@ -202,31 +199,21 @@ namespace Duplicati.Library.Main.Database if (m_tablename != null) try { - using (var cmd = m_connection.CreateCommand()) - { - cmd.Transaction = m_transaction; - cmd.ExecuteNonQuery(string.Format(@"DROP TABLE IF EXISTS ""{0}""", m_tablename)); - } + using (var cmd = m_connection.CreateCommand(m_transaction)) + cmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{m_tablename}""")); } catch { } finally { m_tablename = null; } - if (m_insertCommand != null) - try { m_insertCommand.Dispose(); } - catch { } - finally { m_insertCommand = null; } - - if (m_transaction != null) - try { m_transaction.Rollback(); } - catch { } - finally { m_transaction = null; } + m_insertCommand?.Dispose(); + m_transaction?.Rollback(); } } public interface IFilelist : IDisposable { void Add(string path, long size, string hash, long metasize, string metahash, IEnumerable blocklistHashes, FilelistEntryType type, DateTime time); - IEnumerable> Compare(); + IEnumerable> Compare(); } private class Filelist : Basiclist, IFilelist @@ -234,10 +221,8 @@ namespace Duplicati.Library.Main.Database private const string TABLE_PREFIX = "Filelist"; private const string TABLE_FORMAT = @"(""Path"" TEXT NOT NULL, ""Size"" INTEGER NOT NULL, ""Hash"" TEXT NULL, ""Metasize"" INTEGER NOT NULL, ""Metahash"" TEXT NOT NULL)"; private const string INSERT_COMMAND = @"(""Path"", ""Size"", ""Hash"", ""Metasize"", ""Metahash"") VALUES (?,?,?,?,?)"; - private const int INSERT_ARGUMENTS = 5; - - public Filelist(System.Data.IDbConnection connection, string volumename) - : base(connection, volumename, Filelist.TABLE_PREFIX, Filelist.TABLE_FORMAT, Filelist.INSERT_COMMAND, Filelist.INSERT_ARGUMENTS) + public Filelist(IDbConnection connection, string volumename) + : base(connection, volumename, TABLE_PREFIX, TABLE_FORMAT, INSERT_COMMAND) { } @@ -255,27 +240,25 @@ namespace Duplicati.Library.Main.Database { var cmpName = "CmpTable-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); - var create = @"CREATE TEMPORARY TABLE ""{1}"" AS SELECT ""A"".""Path"" AS ""Path"", CASE WHEN ""B"".""Fullhash"" IS NULL THEN -1 ELSE ""B"".""Length"" END AS ""Size"", ""B"".""Fullhash"" AS ""Hash"", ""C"".""Length"" AS ""Metasize"", ""C"".""Fullhash"" AS ""Metahash"" FROM (SELECT ""File"".""Path"", ""File"".""BlocksetID"" AS ""FileBlocksetID"", ""Metadataset"".""BlocksetID"" AS ""MetadataBlocksetID"" from ""Remotevolume"", ""Fileset"", ""FilesetEntry"", ""File"", ""Metadataset"" WHERE ""Remotevolume"".""Name"" = ? AND ""Fileset"".""VolumeID"" = ""Remotevolume"".""ID"" AND ""Fileset"".""ID"" = ""FilesetEntry"".""FilesetID"" AND ""File"".""ID"" = ""FilesetEntry"".""FileID"" AND ""File"".""MetadataID"" = ""Metadataset"".""ID"") A LEFT OUTER JOIN ""Blockset"" B ON ""B"".""ID"" = ""A"".""FileBlocksetID"" LEFT OUTER JOIN ""Blockset"" C ON ""C"".""ID""=""A"".""MetadataBlocksetID"" "; - var extra = @"SELECT ? AS ""Type"", ""{0}"".""Path"" AS ""Path"" FROM ""{0}"" WHERE ""{0}"".""Path"" NOT IN ( SELECT ""Path"" FROM ""{1}"" )"; - var missing = @"SELECT ? AS ""Type"", ""Path"" AS ""Path"" FROM ""{1}"" WHERE ""Path"" NOT IN (SELECT ""Path"" FROM ""{0}"")"; - var modified = @"SELECT ? AS ""Type"", ""E"".""Path"" AS ""Path"" FROM ""{0}"" E, ""{1}"" D WHERE ""D"".""Path"" = ""E"".""Path"" AND (""D"".""Size"" != ""E"".""Size"" OR ""D"".""Hash"" != ""E"".""Hash"" OR ""D"".""Metasize"" != ""E"".""Metasize"" OR ""D"".""Metahash"" != ""E"".""Metahash"") "; - var drop = @"DROP TABLE IF EXISTS ""{1}"" "; + var create = FormatInvariant($@"CREATE TEMPORARY TABLE ""{cmpName}"" AS SELECT ""A"".""Path"" AS ""Path"", CASE WHEN ""B"".""Fullhash"" IS NULL THEN -1 ELSE ""B"".""Length"" END AS ""Size"", ""B"".""Fullhash"" AS ""Hash"", ""C"".""Length"" AS ""Metasize"", ""C"".""Fullhash"" AS ""Metahash"" FROM (SELECT ""File"".""Path"", ""File"".""BlocksetID"" AS ""FileBlocksetID"", ""Metadataset"".""BlocksetID"" AS ""MetadataBlocksetID"" from ""Remotevolume"", ""Fileset"", ""FilesetEntry"", ""File"", ""Metadataset"" WHERE ""Remotevolume"".""Name"" = ? AND ""Fileset"".""VolumeID"" = ""Remotevolume"".""ID"" AND ""Fileset"".""ID"" = ""FilesetEntry"".""FilesetID"" AND ""File"".""ID"" = ""FilesetEntry"".""FileID"" AND ""File"".""MetadataID"" = ""Metadataset"".""ID"") A LEFT OUTER JOIN ""Blockset"" B ON ""B"".""ID"" = ""A"".""FileBlocksetID"" LEFT OUTER JOIN ""Blockset"" C ON ""C"".""ID""=""A"".""MetadataBlocksetID"" "); + var extra = FormatInvariant($@"SELECT ? AS ""Type"", ""{m_tablename}"".""Path"" AS ""Path"" FROM ""{m_tablename}"" WHERE ""{m_tablename}"".""Path"" NOT IN ( SELECT ""Path"" FROM ""{cmpName}"" )"); + var missing = FormatInvariant($@"SELECT ? AS ""Type"", ""Path"" AS ""Path"" FROM ""{cmpName}"" WHERE ""Path"" NOT IN (SELECT ""Path"" FROM ""{m_tablename}"")"); + var modified = FormatInvariant($@"SELECT ? AS ""Type"", ""E"".""Path"" AS ""Path"" FROM ""{m_tablename}"" E, ""{cmpName}"" D WHERE ""D"".""Path"" = ""E"".""Path"" AND (""D"".""Size"" != ""E"".""Size"" OR ""D"".""Hash"" != ""E"".""Hash"" OR ""D"".""Metasize"" != ""E"".""Metasize"" OR ""D"".""Metahash"" != ""E"".""Metahash"") "); + var drop = FormatInvariant($@"DROP TABLE IF EXISTS ""{cmpName}"" "); - using (var cmd = m_connection.CreateCommand()) + using (var cmd = m_connection.CreateCommand(m_transaction)) { - cmd.Transaction = m_transaction; - try { - cmd.ExecuteNonQuery(string.Format(create, m_tablename, cmpName), m_volumename); - using (var rd = cmd.ExecuteReader(string.Format(extra + " UNION " + missing + " UNION " + modified, m_tablename, cmpName), (int)Library.Interface.TestEntryStatus.Extra, (int)Library.Interface.TestEntryStatus.Missing, (int)Library.Interface.TestEntryStatus.Modified)) + cmd.ExecuteNonQuery(create, m_volumename); + using (var rd = cmd.ExecuteReader(FormatInvariant($"{extra} UNION {missing} UNION {modified}"), (int)Library.Interface.TestEntryStatus.Extra, (int)Library.Interface.TestEntryStatus.Missing, (int)Library.Interface.TestEntryStatus.Modified)) while (rd.Read()) yield return new KeyValuePair((Duplicati.Library.Interface.TestEntryStatus)rd.GetInt64(0), rd.GetString(1)); } finally { - try { cmd.ExecuteNonQuery(string.Format(drop, m_tablename, cmpName)); } + try { cmd.ExecuteNonQuery(drop); } catch { } } } @@ -293,10 +276,9 @@ namespace Duplicati.Library.Main.Database private const string TABLE_PREFIX = "Indexlist"; private const string TABLE_FORMAT = @"(""Name"" TEXT NOT NULL, ""Hash"" TEXT NOT NULL, ""Size"" INTEGER NOT NULL)"; private const string INSERT_COMMAND = @"(""Name"", ""Hash"", ""Size"") VALUES (?,?,?)"; - private const int INSERT_ARGUMENTS = 3; - public Indexlist(System.Data.IDbConnection connection, string volumename) - : base(connection, volumename, Indexlist.TABLE_PREFIX, Indexlist.TABLE_FORMAT, Indexlist.INSERT_COMMAND, Indexlist.INSERT_ARGUMENTS) + public Indexlist(IDbConnection connection, string volumename) + : base(connection, volumename, TABLE_PREFIX, TABLE_FORMAT, INSERT_COMMAND) { } @@ -311,27 +293,25 @@ namespace Duplicati.Library.Main.Database public IEnumerable> Compare() { var cmpName = "CmpTable-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); - var create = @"CREATE TEMPORARY TABLE ""{1}"" AS SELECT ""A"".""Name"", ""A"".""Hash"", ""A"".""Size"" FROM ""Remotevolume"" A, ""Remotevolume"" B, ""IndexBlockLink"" WHERE ""B"".""Name"" = ? AND ""A"".""ID"" = ""IndexBlockLink"".""BlockVolumeID"" AND ""B"".""ID"" = ""IndexBlockLink"".""IndexVolumeID"" "; - var extra = @"SELECT ? AS ""Type"", ""{0}"".""Name"" AS ""Name"" FROM ""{0}"" WHERE ""{0}"".""Name"" NOT IN ( SELECT ""Name"" FROM ""{1}"" )"; - var missing = @"SELECT ? AS ""Type"", ""Name"" AS ""Name"" FROM ""{1}"" WHERE ""Name"" NOT IN (SELECT ""Name"" FROM ""{0}"")"; - var modified = @"SELECT ? AS ""Type"", ""E"".""Name"" AS ""Name"" FROM ""{0}"" E, ""{1}"" D WHERE ""D"".""Name"" = ""E"".""Name"" AND (""D"".""Hash"" != ""E"".""Hash"" OR ""D"".""Size"" != ""E"".""Size"") "; - var drop = @"DROP TABLE IF EXISTS ""{1}"" "; + var create = FormatInvariant($@"CREATE TEMPORARY TABLE ""{cmpName}"" AS SELECT ""A"".""Name"", ""A"".""Hash"", ""A"".""Size"" FROM ""Remotevolume"" A, ""Remotevolume"" B, ""IndexBlockLink"" WHERE ""B"".""Name"" = ? AND ""A"".""ID"" = ""IndexBlockLink"".""BlockVolumeID"" AND ""B"".""ID"" = ""IndexBlockLink"".""IndexVolumeID"" "); + var extra = FormatInvariant($@"SELECT ? AS ""Type"", ""{m_tablename}"".""Name"" AS ""Name"" FROM ""{m_tablename}"" WHERE ""{m_tablename}"".""Name"" NOT IN ( SELECT ""Name"" FROM ""{cmpName}"" )"); + var missing = FormatInvariant($@"SELECT ? AS ""Type"", ""Name"" AS ""Name"" FROM ""{cmpName}"" WHERE ""Name"" NOT IN (SELECT ""Name"" FROM ""{m_tablename}"")"); + var modified = FormatInvariant($@"SELECT ? AS ""Type"", ""E"".""Name"" AS ""Name"" FROM ""{m_tablename}"" E, ""{cmpName}"" D WHERE ""D"".""Name"" = ""E"".""Name"" AND (""D"".""Hash"" != ""E"".""Hash"" OR ""D"".""Size"" != ""E"".""Size"") "); + var drop = FormatInvariant($@"DROP TABLE IF EXISTS ""{cmpName}"" "); - using (var cmd = m_connection.CreateCommand()) + using (var cmd = m_connection.CreateCommand(m_transaction)) { - cmd.Transaction = m_transaction; - try { - cmd.ExecuteNonQuery(string.Format(create, m_tablename, cmpName), m_volumename); - using (var rd = cmd.ExecuteReader(string.Format(extra + " UNION " + missing + " UNION " + modified, m_tablename, cmpName), (int)Library.Interface.TestEntryStatus.Extra, (int)Library.Interface.TestEntryStatus.Missing, (int)Library.Interface.TestEntryStatus.Modified)) + cmd.ExecuteNonQuery(create, m_volumename); + using (var rd = cmd.ExecuteReader(FormatInvariant($"{extra} UNION {missing} UNION {modified}"), (int)Library.Interface.TestEntryStatus.Extra, (int)Library.Interface.TestEntryStatus.Missing, (int)Library.Interface.TestEntryStatus.Modified)) while (rd.Read()) yield return new KeyValuePair((Duplicati.Library.Interface.TestEntryStatus)rd.GetInt64(0), rd.GetString(1)); } finally { - try { cmd.ExecuteNonQuery(string.Format(drop, m_tablename, cmpName)); } + try { cmd.ExecuteNonQuery(drop); } catch { } } } @@ -349,10 +329,9 @@ namespace Duplicati.Library.Main.Database private const string TABLE_PREFIX = "Blocklist"; private const string TABLE_FORMAT = @"(""Hash"" TEXT NOT NULL, ""Size"" INTEGER NOT NULL)"; private const string INSERT_COMMAND = @"(""Hash"", ""Size"") VALUES (?,?)"; - private const int INSERT_ARGUMENTS = 2; - public Blocklist(System.Data.IDbConnection connection, string volumename) - : base(connection, volumename, Blocklist.TABLE_PREFIX, Blocklist.TABLE_FORMAT, Blocklist.INSERT_COMMAND, Blocklist.INSERT_ARGUMENTS) + public Blocklist(IDbConnection connection, string volumename) + : base(connection, volumename, TABLE_PREFIX, TABLE_FORMAT, INSERT_COMMAND) { } public void AddBlock(string hash, long size) @@ -362,33 +341,31 @@ namespace Duplicati.Library.Main.Database m_insertCommand.ExecuteNonQuery(); } - public IEnumerable> Compare() + public IEnumerable> Compare() { var cmpName = "CmpTable-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); var curBlocks = @"SELECT ""Block"".""Hash"" AS ""Hash"", ""Block"".""Size"" AS ""Size"" FROM ""Remotevolume"", ""Block"" WHERE ""Remotevolume"".""Name"" = ? AND ""Remotevolume"".""ID"" = ""Block"".""VolumeID"""; var duplBlocks = @"SELECT ""Block"".""Hash"" AS ""Hash"", ""Block"".""Size"" AS ""Size"" FROM ""DuplicateBlock"", ""Block"" WHERE ""DuplicateBlock"".""VolumeID"" = (SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Name"" = ?) AND ""Block"".""ID"" = ""DuplicateBlock"".""BlockID"""; var delBlocks = @"SELECT ""DeletedBlock"".""Hash"" AS ""Hash"", ""DeletedBlock"".""Size"" AS ""Size"" FROM ""DeletedBlock"", ""RemoteVolume"" WHERE ""RemoteVolume"".""Name"" = ? AND ""RemoteVolume"".""ID"" = ""DeletedBlock"".""VolumeID"""; - var create = @"CREATE TEMPORARY TABLE ""{0}"" AS SELECT DISTINCT ""Hash"" AS ""Hash"", ""Size"" AS ""Size"" FROM ({1} UNION {2} UNION {3})"; - var extra = @"SELECT ? AS ""Type"", ""{0}"".""Hash"" AS ""Hash"" FROM ""{0}"" WHERE ""{0}"".""Hash"" NOT IN ( SELECT ""Hash"" FROM ""{1}"" )"; - var missing = @"SELECT ? AS ""Type"", ""Hash"" AS ""Hash"" FROM ""{1}"" WHERE ""Hash"" NOT IN (SELECT ""Hash"" FROM ""{0}"")"; - var modified = @"SELECT ? AS ""Type"", ""E"".""Hash"" AS ""Hash"" FROM ""{0}"" E, ""{1}"" D WHERE ""D"".""Hash"" = ""E"".""Hash"" AND ""D"".""Size"" != ""E"".""Size"" "; - var drop = @"DROP TABLE IF EXISTS ""{1}"" "; + var create = FormatInvariant($@"CREATE TEMPORARY TABLE ""{cmpName}"" AS SELECT DISTINCT ""Hash"" AS ""Hash"", ""Size"" AS ""Size"" FROM ({curBlocks} UNION {delBlocks} UNION {duplBlocks})"); + var extra = FormatInvariant($@"SELECT ? AS ""Type"", ""{m_tablename}"".""Hash"" AS ""Hash"" FROM ""{m_tablename}"" WHERE ""{m_tablename}"".""Hash"" NOT IN ( SELECT ""Hash"" FROM ""{cmpName}"" )"); + var missing = FormatInvariant($@"SELECT ? AS ""Type"", ""Hash"" AS ""Hash"" FROM ""{cmpName}"" WHERE ""Hash"" NOT IN (SELECT ""Hash"" FROM ""{m_tablename}"")"); + var modified = FormatInvariant($@"SELECT ? AS ""Type"", ""E"".""Hash"" AS ""Hash"" FROM ""{m_tablename}"" E, ""{cmpName}"" D WHERE ""D"".""Hash"" = ""E"".""Hash"" AND ""D"".""Size"" != ""E"".""Size"" "); + var drop = FormatInvariant($@"DROP TABLE IF EXISTS ""{cmpName}"" "); - using (var cmd = m_connection.CreateCommand()) + using (var cmd = m_connection.CreateCommand(m_transaction)) { - cmd.Transaction = m_transaction; - try { - cmd.ExecuteNonQuery(string.Format(create, cmpName, curBlocks, delBlocks, duplBlocks), m_volumename, m_volumename, m_volumename); - using (var rd = cmd.ExecuteReader(string.Format(extra + " UNION " + missing + " UNION " + modified, m_tablename, cmpName), (int)Library.Interface.TestEntryStatus.Extra, (int)Library.Interface.TestEntryStatus.Missing, (int)Library.Interface.TestEntryStatus.Modified)) + cmd.ExecuteNonQuery(create, m_volumename, m_volumename, m_volumename); + using (var rd = cmd.ExecuteReader(FormatInvariant($"{extra} UNION {missing} UNION {modified}"), (int)Library.Interface.TestEntryStatus.Extra, (int)Library.Interface.TestEntryStatus.Missing, (int)Library.Interface.TestEntryStatus.Modified)) while (rd.Read()) yield return new KeyValuePair((Duplicati.Library.Interface.TestEntryStatus)rd.GetInt64(0), rd.GetString(1)); } finally { - try { cmd.ExecuteNonQuery(string.Format(drop, m_tablename, cmpName)); } + try { cmd.ExecuteNonQuery(drop); } catch { } } } diff --git a/Duplicati/Library/Main/Database/RemoteVolume.cs b/Duplicati/Library/Main/Database/RemoteVolume.cs index e2bc5e2e8..92f3bc77b 100644 --- a/Duplicati/Library/Main/Database/RemoteVolume.cs +++ b/Duplicati/Library/Main/Database/RemoteVolume.cs @@ -1,51 +1,47 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. namespace Duplicati.Library.Main.Database { public class RemoteVolume : IRemoteVolume { - public RemoteVolume(Library.Interface.IFileEntry file, string hash = null) + public RemoteVolume(Interface.IFileEntry file, string hash = null) { - this.Name = file.Name; - this.Size = file.Size; - this.Hash = hash; - this.File = file; + Name = file.Name; + Size = file.Size; + Hash = hash; + File = file; } public RemoteVolume(string name, string hash, long size) { - this.Name = name; - this.Hash = hash; - this.Size = size; - this.File = null; + Name = name; + Hash = hash; + Size = size; + File = null; } public string Name { get; private set; } public string Hash { get; private set; } public long Size { get; private set; } - public Library.Interface.IFileEntry File { get; private set; } + public Interface.IFileEntry File { get; private set; } } } diff --git a/Duplicati/Library/Main/Database/ReusableTransaction.cs b/Duplicati/Library/Main/Database/ReusableTransaction.cs new file mode 100644 index 000000000..d83e6c9b2 --- /dev/null +++ b/Duplicati/Library/Main/Database/ReusableTransaction.cs @@ -0,0 +1,70 @@ +using System; +using System.Data; + +#nullable enable + +namespace Duplicati.Library.Main.Database; + +/// +/// Wraps a transaction so it can be comitted and restarted +/// +internal class ReusableTransaction : IDisposable +{ + /// + /// The tag used for logging + /// + private static readonly string LOGTAG = Logging.Log.LogTagFromType(typeof(LocalDatabase)); + + /// + /// The database to use + /// + private readonly LocalDatabase m_db; + /// + /// The current transaction + /// + private IDbTransaction? m_transaction; + + /// + /// Creates a new reusable transaction + /// + /// The database to use + public ReusableTransaction(LocalDatabase db, IDbTransaction? transaction = null) + { + m_db = db; + m_transaction = transaction ?? db.BeginTransaction(); + } + + /// + /// The current transaction + /// + public IDbTransaction Transaction => m_transaction ?? throw new InvalidOperationException("Transaction is disposed"); + + /// + /// Commits the current transaction and optionally restarts it + /// + /// The log message to use + /// True if the transaction should be restarted + public void Commit(string? message = null, bool restart = true) + { + if (m_transaction == null) + throw new InvalidOperationException("Transaction is already disposed"); + + if (m_transaction != null) + { + using (var timer = string.IsNullOrWhiteSpace(message) ? null : new Logging.Timer(LOGTAG, message, "CommitTransaction")) + m_transaction.Commit(); + m_transaction.Dispose(); + m_transaction = null; + } + + if (restart) + m_transaction = m_db.BeginTransaction(); + } + + /// + public void Dispose() + { + m_transaction?.Dispose(); + m_transaction = null; + } +} diff --git a/Duplicati/Library/Main/Duplicati.Library.Main.csproj b/Duplicati/Library/Main/Duplicati.Library.Main.csproj index 88f06d620..ac3cfac6e 100644 --- a/Duplicati/Library/Main/Duplicati.Library.Main.csproj +++ b/Duplicati/Library/Main/Duplicati.Library.Main.csproj @@ -19,6 +19,7 @@ + @@ -34,6 +35,7 @@ + diff --git a/Duplicati/Library/Main/IBackendManager.cs b/Duplicati/Library/Main/IBackendManager.cs index 270f6c946..c9cd0372c 100644 --- a/Duplicati/Library/Main/IBackendManager.cs +++ b/Duplicati/Library/Main/IBackendManager.cs @@ -54,6 +54,15 @@ internal interface IBackendManager : IDisposable /// An enumerable of file entries Task> ListAsync(CancellationToken cancelToken); + /// + /// Decrypts the given file and returns the decrypted file + /// + /// The file to decrypt + /// The name of the file. Used for detecting encryption algorithm if not specified in options or if it differs from the options + /// The Duplicati options + /// The decrypted file + TempFile DecryptFile(TempFile volume, string volume_name, Options options); + /// /// Deletes a file on the backend /// @@ -88,9 +97,19 @@ internal interface IBackendManager : IDisposable /// The hash of the file to get, or null if not known /// The size of the file to get, or -1 if not known /// The cancellation token - /// + /// The downloaded file Task GetAsync(string remotename, string hash, long size, CancellationToken cancelToken); + /// + /// Gets a file from the backend without decrypting it + /// + /// The name of the remote volume + /// The hash of the volume + /// The size of the volume + /// The cancellation token + /// The downloaded file + Task GetDirectAsync(string remotename, string hash, long size, CancellationToken cancelToken); + /// /// Performs a download of the files specified, with pre-fetch to overlap the download and processing /// diff --git a/Duplicati/Library/Main/Operation/Backup/BackupDatabase.cs b/Duplicati/Library/Main/Operation/Backup/BackupDatabase.cs index 372bc2cf5..cc7eb1b39 100644 --- a/Duplicati/Library/Main/Operation/Backup/BackupDatabase.cs +++ b/Duplicati/Library/Main/Operation/Backup/BackupDatabase.cs @@ -255,6 +255,11 @@ namespace Duplicati.Library.Main.Operation.Backup return RunOnMain(() => m_database.WriteFileset(fsw, filesetid, GetTransaction())); } + public Task PushTimestampChangesToPreviousVersionAsync(long filesetid) + { + return RunOnMain(() => m_database.PushTimestampChangesToPreviousVersion(filesetid, GetTransaction())); + } + public Task UpdateFilesetAndMarkAsFullBackupAsync(long filesetid) { return RunOnMain(() => m_database.UpdateFullBackupStateInFileset(filesetid, true, GetTransaction())); diff --git a/Duplicati/Library/Main/Operation/Backup/BackupStatsCollector.cs b/Duplicati/Library/Main/Operation/Backup/BackupStatsCollector.cs index ad898a891..03572ab17 100644 --- a/Duplicati/Library/Main/Operation/Backup/BackupStatsCollector.cs +++ b/Duplicati/Library/Main/Operation/Backup/BackupStatsCollector.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -41,15 +41,25 @@ namespace Duplicati.Library.Main.Operation.Backup public Task AddOpenedFile(long size) { - return RunOnMain(() => { + return RunOnMain(() => + { m_res.SizeOfOpenedFiles += size; m_res.OpenedFiles++; }); } + public Task AddTimestampChangedFile() + { + return RunOnMain(() => + { + m_res.TimestampChangedFiles++; + }); + } + public Task AddAddedFile(long size) { - return RunOnMain(() => { + return RunOnMain(() => + { m_res.SizeOfAddedFiles += size; m_res.AddedFiles++; }); @@ -57,7 +67,8 @@ namespace Duplicati.Library.Main.Operation.Backup public Task AddModifiedFile(long size) { - return RunOnMain(() => { + return RunOnMain(() => + { m_res.SizeOfModifiedFiles += size; m_res.ModifiedFiles++; }); @@ -65,7 +76,8 @@ namespace Duplicati.Library.Main.Operation.Backup public Task AddExaminedFile(long size) { - return RunOnMain(() => { + return RunOnMain(() => + { m_res.SizeOfExaminedFiles += size; m_res.ExaminedFiles++; }); diff --git a/Duplicati/Library/Main/Operation/Backup/Channels.cs b/Duplicati/Library/Main/Operation/Backup/Channels.cs index 6a6576f67..f65c545d7 100644 --- a/Duplicati/Library/Main/Operation/Backup/Channels.cs +++ b/Duplicati/Library/Main/Operation/Backup/Channels.cs @@ -20,6 +20,7 @@ // DEALINGS IN THE SOFTWARE. using CoCoL; +using Duplicati.Library.Interface; namespace Duplicati.Library.Main.Operation.Backup { @@ -51,7 +52,7 @@ namespace Duplicati.Library.Main.Operation.Backup /// /// When enumerating the source folders, all discovered paths are sent by the to the /// - public readonly IChannel SourcePaths = ChannelManager.CreateChannel(); + public readonly IChannel SourcePaths = ChannelManager.CreateChannel(); /// /// All progress events are communicated over this channel, to ensure that parallel progress is reported as a if it was sequential /// diff --git a/Duplicati/Library/Main/Operation/Backup/CountFilesHandler.cs b/Duplicati/Library/Main/Operation/Backup/CountFilesHandler.cs index 83f46b264..1d16f29d9 100644 --- a/Duplicati/Library/Main/Operation/Backup/CountFilesHandler.cs +++ b/Duplicati/Library/Main/Operation/Backup/CountFilesHandler.cs @@ -1,42 +1,40 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System.Collections.Generic; using System.Threading.Tasks; using Duplicati.Library.Utility; -using Duplicati.Library.Snapshots; using CoCoL; -using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using Duplicati.Library.Snapshots.USN; namespace Duplicati.Library.Main.Operation.Backup { internal static class CountFilesHandler { public static async Task Run( - IEnumerable sources, - ISnapshotService snapshot, + ISourceProvider sources, UsnJournalService journalService, BackupResults result, Options options, - IFilter sourcefilter, IFilter filter, HashSet blacklistPaths, Common.ITaskReader taskreader, @@ -47,9 +45,8 @@ namespace Duplicati.Library.Main.Operation.Backup using (Logging.Log.StartIsolatingScope(true)) { Channels channels = new(); - var enumeratorTask = Backup.FileEnumerationProcess.Run( - channels, sources, snapshot, journalService, - options.FileAttributeFilter, sourcefilter, filter, + var enumeratorTask = FileEnumerationProcess.Run( + channels, sources, journalService, options.FileAttributeFilter, filter, options.SymlinkPolicy, options.HardlinkPolicy, options.ExcludeEmptyFolders, options.IgnoreFilenames, blacklistPaths, options.ChangedFilelist, taskreader, null, token); @@ -68,14 +65,15 @@ namespace Duplicati.Library.Main.Operation.Backup { while (await taskreader.ProgressRendevouz() && !token.IsCancellationRequested) { - var path = await self.Input.ReadAsync(); + var entry = await self.Input.ReadAsync(); + if (entry.IsFolder) + continue; count++; try { - if (SystemIO.IO_OS.FileExists(path)) - size += snapshot.GetFileSize(path); + size += entry.Size; } catch { diff --git a/Duplicati/Library/Main/Operation/Backup/DataBlockProcessor.cs b/Duplicati/Library/Main/Operation/Backup/DataBlockProcessor.cs index 7b43c4d36..670f3a809 100644 --- a/Duplicati/Library/Main/Operation/Backup/DataBlockProcessor.cs +++ b/Duplicati/Library/Main/Operation/Backup/DataBlockProcessor.cs @@ -119,7 +119,7 @@ namespace Duplicati.Library.Main.Operation.Backup indexvolume.AddBlock(b.HashKey, b.Size); // If the volume is full, send to upload - if (blockvolume.Filesize > options.VolumeSize - options.Blocksize) + if (blockvolume.Filesize > (options.VolumeSize - options.Blocksize)) { //When uploading a new volume, we register the volumes and then flush the transaction // this ensures that the local database and remote storage are as closely related as possible diff --git a/Duplicati/Library/Main/Operation/Backup/FileBlockProcessor.cs b/Duplicati/Library/Main/Operation/Backup/FileBlockProcessor.cs index 7dcfead03..02a983aac 100644 --- a/Duplicati/Library/Main/Operation/Backup/FileBlockProcessor.cs +++ b/Duplicati/Library/Main/Operation/Backup/FileBlockProcessor.cs @@ -1,29 +1,28 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using CoCoL; using Duplicati.Library.Main.Operation.Common; using System.Threading.Tasks; -using System.Threading; namespace Duplicati.Library.Main.Operation.Backup { @@ -37,7 +36,7 @@ namespace Duplicati.Library.Main.Operation.Backup /// private static readonly string FILELOGTAG = Logging.Log.LogTagFromType(typeof(FileBlockProcessor)) + ".FileEntry"; - public static Task Run(Channels channels, Snapshots.ISnapshotService snapshot, Options options, BackupDatabase database, BackupStatsCollector stats, ITaskReader taskreader) + public static Task Run(Channels channels, Options options, BackupDatabase database, BackupStatsCollector stats, ITaskReader taskreader) { return AutomationExtensions.RunTask( new @@ -57,7 +56,7 @@ namespace Duplicati.Library.Main.Operation.Backup try { - var hint = options.GetCompressionHintFromFilename(e.Path); + var hint = options.GetCompressionHintFromFilename(e.Entry.Path); var oldHash = e.OldId < 0 ? null : await database.GetFileHashAsync(e.OldId); StreamProcessResult filestreamdata; @@ -73,15 +72,15 @@ namespace Duplicati.Library.Main.Operation.Backup if (res.Item1) return res.Item2; - Logging.Log.WriteWarningMessage(FILELOGTAG, "UnexpectedMetadataLookup", null, "Metadata was reported as not changed, but still requires being added?\nHash: {0}, Length: {1}, ID: {2}, Path: {3}", e.MetaHashAndSize.FileHash, e.MetaHashAndSize.Blob.Length, res.Item2, e.Path); + Logging.Log.WriteWarningMessage(FILELOGTAG, "UnexpectedMetadataLookup", null, "Metadata was reported as not changed, but still requires being added?\nHash: {0}, Length: {1}, ID: {2}, Path: {3}", e.MetaHashAndSize.FileHash, e.MetaHashAndSize.Blob.Length, res.Item2, e.Entry.Path); e.MetadataChanged = true; } - return (await MetadataPreProcess.AddMetadataToOutputAsync(e.Path, e.MetaHashAndSize, database, self.StreamBlockChannel)).Item2; + return (await MetadataPreProcess.AddMetadataToOutputAsync(e.Entry.Path, e.MetaHashAndSize, database, self.StreamBlockChannel)).Item2; }); - using (var fs = snapshot.OpenRead(e.Path)) - filestreamdata = await StreamBlock.ProcessStream(self.StreamBlockChannel, e.Path, fs, false, hint); + using (var fs = await e.Entry.OpenRead(taskreader.ProgressToken)) + filestreamdata = await StreamBlock.ProcessStream(self.StreamBlockChannel, e.Entry.Path, fs, false, hint); await stats.AddOpenedFile(filestreamdata.Streamlength); @@ -92,36 +91,42 @@ namespace Duplicati.Library.Main.Operation.Backup if (oldHash != filekey) { if (oldHash == null) - Logging.Log.WriteVerboseMessage(FILELOGTAG, "NewFile", "New file {0}", e.Path); + Logging.Log.WriteVerboseMessage(FILELOGTAG, "NewFile", "New file {0}", e.Entry.Path); else - Logging.Log.WriteVerboseMessage(FILELOGTAG, "ChangedFile", "File has changed {0}", e.Path); + Logging.Log.WriteVerboseMessage(FILELOGTAG, "ChangedFile", "File has changed {0}", e.Entry.Path); if (e.OldId < 0) { await stats.AddAddedFile(filesize); if (options.Dryrun) - Logging.Log.WriteVerboseMessage(FILELOGTAG, "WouldAddNewFile", "Would add new file {0}, size {1}", e.Path, Library.Utility.Utility.FormatSizeString(filesize)); + Logging.Log.WriteVerboseMessage(FILELOGTAG, "WouldAddNewFile", "Would add new file {0}, size {1}", e.Entry.Path, Library.Utility.Utility.FormatSizeString(filesize)); } else { await stats.AddModifiedFile(filesize); if (options.Dryrun) - Logging.Log.WriteVerboseMessage(FILELOGTAG, "WouldAddChangedFile", "Would add changed file {0}, size {1}", e.Path, Library.Utility.Utility.FormatSizeString(filesize)); + Logging.Log.WriteVerboseMessage(FILELOGTAG, "WouldAddChangedFile", "Would add changed file {0}, size {1}", e.Entry.Path, Library.Utility.Utility.FormatSizeString(filesize)); } await database.AddFileAsync(e.PathPrefixID, e.Filename, e.LastWrite, filestreamdata.Blocksetid, metadataid); } else if (e.MetadataChanged) { - Logging.Log.WriteVerboseMessage(FILELOGTAG, "FileMetadataChanged", "File has only metadata changes {0}", e.Path); + Logging.Log.WriteVerboseMessage(FILELOGTAG, "FileMetadataChanged", "File has only metadata changes {0}", e.Entry.Path); await database.AddFileAsync(e.PathPrefixID, e.Filename, e.LastWrite, filestreamdata.Blocksetid, metadataid); } + else if (e.TimestampChanged) + { + await stats.AddTimestampChangedFile(); + Logging.Log.WriteVerboseMessage(FILELOGTAG, "FileTimestampChanged", "File has only timestamp changes {0}", e.Entry.Path); + await database.AddUnmodifiedAsync(e.OldId, e.LastWrite); + } else /*if (e.OldId >= 0)*/ { // When we write the file to output, update the last modified time - Logging.Log.WriteVerboseMessage(FILELOGTAG, "NoFileChanges", "File has not changed {0}", e.Path); + Logging.Log.WriteVerboseMessage(FILELOGTAG, "NoFileChanges", "File has not changed {0}", e.Entry.Path); try { @@ -129,7 +134,7 @@ namespace Duplicati.Library.Main.Operation.Backup } catch (Exception ex) { - Logging.Log.WriteWarningMessage(FILELOGTAG, "FailedToAddFile", ex, "Failed while attempting to add unmodified file to database: {0}", e.Path); + Logging.Log.WriteWarningMessage(FILELOGTAG, "FailedToAddFile", ex, "Failed while attempting to add unmodified file to database: {0}", e.Entry.Path); } } } @@ -138,7 +143,7 @@ namespace Duplicati.Library.Main.Operation.Backup if (ex.IsRetiredException()) return; else - Logging.Log.WriteWarningMessage(FILELOGTAG, "PathProcessingFailed", ex, "Failed to process path: {0}", e.Path); + Logging.Log.WriteWarningMessage(FILELOGTAG, "PathProcessingFailed", ex, "Failed to process path: {0}", e.Entry.Path); } } }); diff --git a/Duplicati/Library/Main/Operation/Backup/FileEnumerationProcess.cs b/Duplicati/Library/Main/Operation/Backup/FileEnumerationProcess.cs index 7b7478a82..848610186 100644 --- a/Duplicati/Library/Main/Operation/Backup/FileEnumerationProcess.cs +++ b/Duplicati/Library/Main/Operation/Backup/FileEnumerationProcess.cs @@ -19,6 +19,8 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +#nullable enable + using System; using CoCoL; using System.Threading.Tasks; @@ -27,9 +29,10 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using Duplicati.Library.Main.Operation.Common; -using Duplicati.Library.Snapshots; -using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; +using System.Runtime.CompilerServices; +using Duplicati.Library.SourceProvider; +using Duplicati.Library.Snapshots.USN; namespace Duplicati.Library.Main.Operation.Backup { @@ -47,20 +50,18 @@ namespace Duplicati.Library.Main.Operation.Backup public static Task Run( Channels channels, - IEnumerable sources, - ISnapshotService snapshot, - UsnJournalService journalService, - FileAttributes fileAttributes, - Library.Utility.IFilter sourcefilter, + ISourceProvider sourceProvider, + UsnJournalService? journalService, + FileAttributes fileAttributeFilter, Library.Utility.IFilter emitfilter, Options.SymlinkStrategy symlinkPolicy, Options.HardlinkStrategy hardlinkPolicy, bool excludeemptyfolders, - string[] ignorenames, + string[]? ignorenames, HashSet blacklistPaths, - string[] changedfilelist, + IEnumerable? changedfilelist, ITaskReader taskreader, - Action onStopRequested, + Action? onStopRequested, CancellationToken token) { return AutomationExtensions.RunTask( @@ -73,9 +74,19 @@ namespace Duplicati.Library.Main.Operation.Backup { if (!token.IsCancellationRequested) { + // The hardlink map tracks the hardlink targets we have seen + // and avoid multiple processing of the same contents var hardlinkmap = new Dictionary(); - var mixinqueue = new Queue(); - Duplicati.Library.Utility.IFilter enumeratefilter = emitfilter; + + // The mixin queue is used to store symlinks that should be processed + // The symlinks are emitted during the enumeration process when they are found + var mixinqueue = new Queue(); + + // The enumeration filter is used to determine what paths to + // recurse into. If the emit filter only has includes, + // the enumeration filter will also include all folders, + // as nothing will match otherwise + var enumeratefilter = emitfilter; Library.Utility.FilterExpression.AnalyzeFilters(emitfilter, out var includes, out var excludes); if (includes && !excludes) @@ -85,59 +96,82 @@ namespace Duplicati.Library.Main.Operation.Backup if (ignorenames != null && ignorenames.Length == 0) ignorenames = null; + + // Shared filter function with bound variables + ValueTask FilterEntry(ISourceProviderEntry entry) + => SourceFileEntryFilter(entry, blacklistPaths, hardlinkPolicy, symlinkPolicy, hardlinkmap, fileAttributeFilter, enumeratefilter, ignorenames, mixinqueue, token); + + // Prepare the work list + IAsyncEnumerable worklist; + // If we have a specific list, use that instead of enumerating the filesystem - IEnumerable worklist; - if (changedfilelist != null && changedfilelist.Length > 0) + if (changedfilelist != null && changedfilelist.Any()) { - worklist = changedfilelist.Where(x => + async IAsyncEnumerable ExpandSources(IEnumerable list) { - var fa = FileAttributes.Normal; - try - { - fa = snapshot.GetAttributes(x); - } - catch + foreach (var s in list) { + var r = await sourceProvider.GetEntry(s, s.EndsWith(Path.DirectorySeparatorChar), token).ConfigureAwait(false); + if (r != null) + { + //TODO: Set r.IsRoot = true for source elements + yield return r; + } } + } + worklist = ExpandSources(changedfilelist).WhereAwait(FilterEntry); + } + else if (journalService != null) + { + if (!OperatingSystem.IsWindows()) + throw new NotSupportedException("USN is only supported on Windows"); - if (token.IsCancellationRequested) - { - return false; - } + var fileProviders = (sourceProvider is Combiner c ? c.Providers.AsEnumerable() : [sourceProvider]) + .OfType() + .ToList(); - return AttributeFilter(x, fa, snapshot, sourcefilter, blacklistPaths, hardlinkPolicy, symlinkPolicy, hardlinkmap, fileAttributes, enumeratefilter, ignorenames, mixinqueue); - }); + if (fileProviders.Count <= 0) + throw new InvalidOperationException("No file providers found, but USN was enabled?"); + if (fileProviders.Count > 1) + throw new InvalidOperationException("Multiple file providers found, but USN only supports one"); + + // TODO: This is not as effecient as possible. + // If the root folder is marked changed by USN, the expansion with RecurseEntries + // will cause a full regular scan. It should be possible to *only* process the + // changed elements as returned from the USN journal. + // It should be possible to remove RecurseEntries from the GetModifiedSources() + // enumeration result. + // Such a change requires significant testing as there are many pitfalls with USN. + worklist = RecurseEntries(journalService.GetModifiedSources(FilterEntry, token), + FilterEntry, + token + ) + .Concat( + RecurseEntries(journalService.GetFullScanSources(token), + FilterEntry, + token) + ); } else { - Library.Utility.Utility.EnumerationFilterDelegate attributeFilter = (root, path, attr) => - AttributeFilter(path, attr, snapshot, sourcefilter, blacklistPaths, hardlinkPolicy, symlinkPolicy, hardlinkmap, fileAttributes, enumeratefilter, ignorenames, mixinqueue); - - if (journalService != null) - { - if (!OperatingSystem.IsWindows()) - throw new UserInformationException("USN journal is only supported on Windows", "USNJournalNotSupported"); - // filter sources using USN journal, to obtain a sub-set of files / folders that may have been modified - sources = journalService.GetModifiedSources(attributeFilter); - } - - worklist = snapshot.EnumerateFilesAndFolders(sources, attributeFilter, (rootpath, errorpath, ex) => - { - Logging.Log.WriteWarningMessage(FILTER_LOGTAG, "FileAccessError", ex, "Error reported while accessing file: {0}", errorpath); - }).Distinct(Library.Utility.Utility.IsFSCaseSensitive ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase); + worklist = RecurseEntries(sourceProvider.Enumerate(token), + FilterEntry, + token + ); } if (token.IsCancellationRequested) - { return; - } - var source = ExpandWorkList(worklist, mixinqueue, emitfilter, enumeratefilter); + var source = ExpandWorkList(worklist, mixinqueue, emitfilter, enumeratefilter, token); + // TODO: There was a call to DistinctBy here, but this would cause all paths to be stored in memory + //.DistinctBy(x => x.Path, Library.Utility.Utility.IsFSCaseSensitive ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase); + if (excludeemptyfolders) - source = ExcludeEmptyFolders(source); + source = ExcludeEmptyFolders(source, token); // Process each path, and dequeue the mixins with symlinks as we go - foreach (var s in source) + await foreach (var s in source.WithCancellation(token).ConfigureAwait(false)) { #if DEBUG // For testing purposes, we need exact control @@ -146,7 +180,7 @@ namespace Duplicati.Library.Main.Operation.Backup // if the process is the real file enumeration process // because the counter processe does not have a callback if (onStopRequested != null) - taskreader.TestMethodCallback?.Invoke(s); + taskreader.TestMethodCallback?.Invoke(s.Path); #endif // Stop if requested if (token.IsCancellationRequested || !await taskreader.ProgressRendevouz().ConfigureAwait(false)) @@ -167,13 +201,13 @@ namespace Duplicati.Library.Main.Operation.Backup private class DirectoryStackEntry { /// - /// The path for the folder + /// The item being tracked /// - public string Path; + public required ISourceProviderEntry Item; /// /// A flag indicating if any items are found in this folder /// - public bool AnyEntries; + public required bool AnyEntries; } @@ -182,17 +216,17 @@ namespace Duplicati.Library.Main.Operation.Backup /// /// The list without empty folders. /// The list with potential empty folders. - private static IEnumerable ExcludeEmptyFolders(IEnumerable source) + private static async IAsyncEnumerable ExcludeEmptyFolders(IAsyncEnumerable source, [EnumeratorCancellation] CancellationToken cancellationToken) { var pathstack = new Stack(); - foreach (var s in source) + await foreach (var s in source.WithCancellation(cancellationToken).ConfigureAwait(false)) { // Keep track of directories - var isDirectory = s[s.Length - 1] == System.IO.Path.DirectorySeparatorChar; + var isDirectory = s.Path[s.Path.Length - 1] == System.IO.Path.DirectorySeparatorChar; if (isDirectory) { - while (pathstack.Count > 0 && !s.StartsWith(pathstack.Peek().Path, Library.Utility.Utility.ClientFilenameStringComparison)) + while (pathstack.Count > 0 && !s.Path.StartsWith(pathstack.Peek().Item.Path, Library.Utility.Utility.ClientFilenameStringComparison)) { var e = pathstack.Pop(); if (e.AnyEntries || pathstack.Count == 0) @@ -201,15 +235,15 @@ namespace Duplicati.Library.Main.Operation.Backup if (pathstack.Count > 0) pathstack.Peek().AnyEntries = true; - yield return e.Path; + yield return e.Item; } else - Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingEmptyFolder", "Excluding empty folder {0}", e.Path); + Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingEmptyFolder", "Excluding empty folder {0}", e.Item); } - if (pathstack.Count == 0 || s.StartsWith(pathstack.Peek().Path, Library.Utility.Utility.ClientFilenameStringComparison)) + if (pathstack.Count == 0 || s.Path.StartsWith(pathstack.Peek().Item.Path, Library.Utility.Utility.ClientFilenameStringComparison)) { - pathstack.Push(new DirectoryStackEntry() { Path = s }); + pathstack.Push(new DirectoryStackEntry() { Item = s, AnyEntries = false }); continue; } } @@ -231,7 +265,46 @@ namespace Duplicati.Library.Main.Operation.Backup if (pathstack.Count > 0) pathstack.Peek().AnyEntries = true; - yield return e.Path; + yield return e.Item; + } + } + } + + /// + /// Performs recursive traversal of the sources + /// + /// The entries to recurse + /// The filter to apply + /// + private static async IAsyncEnumerable RecurseEntries(IAsyncEnumerable entries, Func> filter, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var work = new Stack(); + + await foreach (var e in entries.WithCancellation(cancellationToken).ConfigureAwait(false)) + if (await filter(e).ConfigureAwait(false)) + work.Push(e); + + while (work.Count > 0) + { + var e = work.Pop(); + + // Process meta entry contents, but don't emit them for processing + if (!e.IsMetaEntry) + yield return e; + + if (e.IsFolder) + { + try + { + // We only filter new items, as we assume the input is already filtered + await foreach (var r in e.Enumerate(cancellationToken).ConfigureAwait(false)) + if (await filter(r).ConfigureAwait(false)) + work.Push(r); + } + catch (Exception ex) + { + Logging.Log.WriteWarningMessage(FILTER_LOGTAG, "PathProcessingErrorEnumerate", ex, "Failed to enumerate path: {0}", e.Path); + } } } } @@ -244,16 +317,18 @@ namespace Duplicati.Library.Main.Operation.Backup /// The mix in queue. /// The emitfilter. /// The enumeratefilter. - private static IEnumerable ExpandWorkList(IEnumerable worklist, Queue mixinqueue, Library.Utility.IFilter emitfilter, Library.Utility.IFilter enumeratefilter) + private static async IAsyncEnumerable ExpandWorkList(IAsyncEnumerable worklist, Queue mixinqueue, Library.Utility.IFilter emitfilter, Library.Utility.IFilter enumeratefilter, [EnumeratorCancellation] CancellationToken cancellationToken) { // Process each path, and dequeue the mixins with symlinks as we go - foreach (var s in worklist) + await foreach (var s in worklist.WithCancellation(cancellationToken).ConfigureAwait(false)) { while (mixinqueue.Count > 0) yield return mixinqueue.Dequeue(); - Library.Utility.IFilter m; - if (emitfilter != enumeratefilter && !Library.Utility.FilterExpression.Matches(emitfilter, s, out m)) + // If there are only includes in the filter, check if the item is in the original filter + // Since the enumerate filter also includes all folders, we need to ensure we do not emit + // any entries that are filtered explicitly by the user + if (emitfilter != enumeratefilter && !Library.Utility.FilterExpression.Matches(emitfilter, s.Path, out var _)) continue; yield return s; @@ -265,165 +340,208 @@ namespace Duplicati.Library.Main.Operation.Backup } /// - /// Plugin filter for enumerating a list of files. + /// Performs a pre-filter on the source entry to see if it should be included in the backup /// - /// True if the path should be returned, false otherwise. - /// The current path. - /// The file or folder attributes. - /// The snapshot service. - /// The source filter. + /// The entry to evaluate. /// The blacklist paths. - /// The hardlink policy. - /// The symlink policy. - /// The hardlink map. - /// The file attributes to exclude. - /// The enumerate filter. - /// The ignore names. - /// The mixin queue. - private static bool AttributeFilter(string path, FileAttributes attributes, Snapshots.ISnapshotService snapshot, Library.Utility.IFilter sourcefilter, HashSet blacklistPaths, Options.HardlinkStrategy hardlinkPolicy, Options.SymlinkStrategy symlinkPolicy, Dictionary hardlinkmap, FileAttributes fileAttributes, Duplicati.Library.Utility.IFilter enumeratefilter, string[] ignorenames, Queue mixinqueue) + /// True if the path should be returned, false otherwise. + private static bool PreFilterSourceEntry(ISourceProviderEntry entry, HashSet blacklistPaths) { + // Don't filter meta stuff + if (entry.IsMetaEntry) + return true; + // Exclude any blacklisted paths - if (blacklistPaths.Contains(path)) + if (blacklistPaths.Contains(entry.Path)) { - Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingBlacklistedPath", "Excluding blacklisted path: {0}", path); + Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingBlacklistedPath", "Excluding blacklisted path: {0}", entry.Path); return false; } // Exclude block devices try { - if (snapshot.IsBlockDevice(path)) + if (entry.IsBlockDevice) { - Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingBlockDevice", "Excluding block device: {0}", path); + Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingBlockDevice", "Excluding block device: {0}", entry.Path); return false; } } catch (Exception ex) { - Logging.Log.WriteWarningMessage(FILTER_LOGTAG, "PathProcessingErrorBlockDevice", ex, "Failed to process path: {0}", path); + Logging.Log.WriteWarningMessage(FILTER_LOGTAG, "PathProcessingErrorBlockDevice", ex, "Failed to process path: {0}", entry.Path); return false; } - // Check if we explicitly include this entry - Duplicati.Library.Utility.IFilter sourcematch; - bool sourcematches; - if (sourcefilter.Matches(path, out sourcematches, out sourcematch) && sourcematches) + // Exclude character devices + try { - Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "IncludingSourcePath", "Including source path: {0}", path); - return true; + if (entry.IsCharacterDevice) + { + Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingCharacterDevice", "Excluding character device: {0}", entry.Path); + return false; + } } + catch (Exception ex) + { + Logging.Log.WriteWarningMessage(FILTER_LOGTAG, "PathProcessingErrorCharacterDevice", ex, "Failed to process path: {0}", entry.Path); + return false; + } + + return true; + } + + /// + /// Evaluates a single entry for inclusion in the backup + /// + /// The current entry. + /// The snapshot service. + /// The blacklist paths. + /// The hardlink policy. + /// The symlink policy. + /// The hardlink map. + /// The file attributes to exclude. + /// The enumerate filter. + /// The ignore names. + /// The mixin queue. + /// True if the path should be returned, false otherwise. + private static async ValueTask SourceFileEntryFilter(ISourceProviderEntry entry, HashSet blacklistPaths, Options.HardlinkStrategy hardlinkPolicy, Options.SymlinkStrategy symlinkPolicy, Dictionary hardlinkmap, FileAttributes fileAttributeFilter, Duplicati.Library.Utility.IFilter enumeratefilter, string[]? ignorenames, Queue mixinqueue, CancellationToken cancellationToken) + { + // Do the course pre-filtering first + if (!PreFilterSourceEntry(entry, blacklistPaths)) + return false; + + // Never exclude the root entries + if (entry.IsRootEntry) + return true; // If we have a hardlink strategy, obey it if (hardlinkPolicy != Options.HardlinkStrategy.All) { try { - var id = snapshot.HardlinkTargetID(path); + var id = entry.HardlinkTargetId; if (id != null) { if (hardlinkPolicy == Options.HardlinkStrategy.None) { - Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingHardlinkByPolicy", "Excluding hardlink: {0} ({1})", path, id); + Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingHardlinkByPolicy", "Excluding hardlink: {0} ({1})", entry.Path, id); return false; } else if (hardlinkPolicy == Options.HardlinkStrategy.First) { - string prevPath; - if (hardlinkmap.TryGetValue(id, out prevPath)) + if (hardlinkmap.TryGetValue(id, out var prevPath)) { - Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingDuplicateHardlink", "Excluding hardlink ({1}) for: {0}, previous hardlink: {2}", path, id, prevPath); + Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingDuplicateHardlink", "Excluding hardlink ({1}) for: {0}, previous hardlink: {2}", entry.Path, id, prevPath); return false; } else { - hardlinkmap.Add(id, path); + hardlinkmap.Add(id, entry.Path); } } } } catch (Exception ex) { - Logging.Log.WriteWarningMessage(FILTER_LOGTAG, "PathProcessingErrorHardLink", ex, "Failed to process path: {0}", path); + Logging.Log.WriteWarningMessage(FILTER_LOGTAG, "PathProcessingErrorHardLink", ex, "Failed to process path: {0}", entry.Path); return false; } } - if (ignorenames != null && (attributes & FileAttributes.Directory) != 0) + // Check if there is an ignore marker file + if (ignorenames != null && entry.IsFolder) { try { foreach (var n in ignorenames) { - var ignorepath = SystemIO.IO_OS.PathCombine(path, n); - if (snapshot.FileExists(ignorepath)) + if (await entry.FileExists(n, cancellationToken).ConfigureAwait(false)) { - Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingPathDueToIgnoreFile", "Excluding path because ignore file was found: {0}", ignorepath); + Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingPathDueToIgnoreFile", "Excluding path because ignore file {0} was found in: {1}", n, entry.Path); return false; } } } catch (Exception ex) { - Logging.Log.WriteWarningMessage(FILTER_LOGTAG, "PathProcessingErrorIgnoreFile", ex, "Failed to process path: {0}", path); + Logging.Log.WriteWarningMessage(FILTER_LOGTAG, "PathProcessingErrorIgnoreFile", ex, "Failed to process path: {0}", entry.Path); } } - // If we exclude files based on attributes, filter that - if ((fileAttributes & attributes) != 0) + // Setup some basic processing attributes + var attributes = entry.IsFolder + ? FileAttributes.Directory + : FileAttributes.Normal; + + try { - Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingPathFromAttributes", "Excluding path due to attribute filter: {0}", path); + attributes = entry.Attributes; + } + catch (Exception ex) + { + Logging.Log.WriteWarningMessage(FILTER_LOGTAG, "PathProcessingErrorAttributes", ex, "Failed to process path, using default attributes: {0}", entry.Path); + } + + // If we exclude files based on attributes, filter that + if ((fileAttributeFilter & attributes) != 0) + { + Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingPathFromAttributes", "Excluding path due to attribute filter: {0}", entry.Path); return false; } // Then check if the filename is not explicitly excluded by a filter - Library.Utility.IFilter match; var filtermatch = false; - if (!Library.Utility.FilterExpression.Matches(enumeratefilter, path, out match)) + if (!Library.Utility.FilterExpression.Matches(enumeratefilter, entry.Path, out var match)) { - Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingPathFromFilter", "Excluding path due to filter: {0} => {1}", path, match == null ? "null" : match.ToString()); + Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingPathFromFilter", "Excluding path due to filter: {0} => {1}", entry.Path, match == null ? "null" : match.ToString()); return false; } else if (match != null) { filtermatch = true; - Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "IncludingPathFromFilter", "Including path due to filter: {0} => {1}", path, match.ToString()); + Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "IncludingPathFromFilter", "Including path due to filter: {0} => {1}", entry.Path, match.ToString()); } // If the file is a symlink, apply special handling - var isSymlink = snapshot.IsSymlink(path, attributes); - string symlinkTarget = null; - if (isSymlink) - try { symlinkTarget = snapshot.GetSymlinkTarget(path); } - catch (Exception ex) { Logging.Log.WriteExplicitMessage(FILTER_LOGTAG, "SymlinkTargetReadError", ex, "Failed to read symlink target for path: {0}", path); } + string? symlinkTarget = null; + try + { + symlinkTarget = entry.SymlinkTarget; + } + catch (Exception ex) + { + Logging.Log.WriteExplicitMessage(FILTER_LOGTAG, "SymlinkTargetReadError", ex, "Failed to read symlink target for path: {0}", entry.Path); + } - if (isSymlink) + if (symlinkTarget != null) { if (!string.IsNullOrWhiteSpace(symlinkTarget)) { if (symlinkPolicy == Options.SymlinkStrategy.Ignore) { - Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludeSymlink", "Excluding symlink: {0}", path); + Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludeSymlink", "Excluding symlink: {0}", entry.Path); return false; } if (symlinkPolicy == Options.SymlinkStrategy.Store) { - Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "StoreSymlink", "Storing symlink: {0}", path); + Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "StoreSymlink", "Storing symlink: {0}", entry.Path); // We return false because we do not want to recurse into the path, // but we add the symlink to the mixin so we process the symlink itself - mixinqueue.Enqueue(path); + mixinqueue.Enqueue(entry); return false; } } else { - Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "FollowingEmptySymlink", "Treating empty symlink as regular path {0}", path); + Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "FollowingEmptySymlink", "Treating empty symlink as regular path {0}", entry.Path); } } if (!filtermatch) - Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "IncludingPath", "Including path as no filters matched: {0}", path); + Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "IncludingPath", "Including path as no filters matched: {0}", entry.Path); // All the way through, yes! return true; diff --git a/Duplicati/Library/Main/Operation/Backup/FilePreFilterProcess.cs b/Duplicati/Library/Main/Operation/Backup/FilePreFilterProcess.cs index 4276fdefc..03e651c17 100644 --- a/Duplicati/Library/Main/Operation/Backup/FilePreFilterProcess.cs +++ b/Duplicati/Library/Main/Operation/Backup/FilePreFilterProcess.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -40,7 +40,7 @@ namespace Duplicati.Library.Main.Operation.Backup /// private static readonly string FILELOGTAG = Logging.Log.LogTagFromType(typeof(FilePreFilterProcess)) + ".FileEntry"; - public static Task Run(Channels channels, Snapshots.ISnapshotService snapshot, Options options, BackupStatsCollector stats, BackupDatabase database) + public static Task Run(Channels channels, Options options, BackupStatsCollector stats, BackupDatabase database) { return AutomationExtensions.RunTask( new @@ -73,11 +73,11 @@ namespace Duplicati.Library.Main.Operation.Backup long filestatsize = -1; try { - filestatsize = snapshot.GetFileSize(e.Path); + filestatsize = e.Entry.Size; } - catch(Exception ex) + catch (Exception ex) { - Logging.Log.WriteExplicitMessage(FILELOGTAG, "FailedToReadSize", ex, "Failed to read size of file: {0}", e.Path); + Logging.Log.WriteExplicitMessage(FILELOGTAG, "FailedToReadSize", ex, "Failed to read size of file: {0}", e.Entry.Path); } await stats.AddExaminedFile(filestatsize); @@ -86,8 +86,8 @@ namespace Duplicati.Library.Main.Operation.Backup var tooLargeFile = SKIPFILESLARGERTHAN != 0 && filestatsize >= 0 && filestatsize > SKIPFILESLARGERTHAN; if (tooLargeFile) { - Logging.Log.WriteVerboseMessage(FILELOGTAG, "SkipCheckTooLarge", "Skipped checking file, because the size exceeds limit {0}", e.Path); - await self.ProgressChannel.WriteAsync(new ProgressEvent() { Filepath = e.Path, Length = filestatsize, Type = EventType.FileSkipped }); + Logging.Log.WriteVerboseMessage(FILELOGTAG, "SkipCheckTooLarge", "Skipped checking file, because the size exceeds limit {0}", e.Entry.Path); + await self.ProgressChannel.WriteAsync(new ProgressEvent() { Filepath = e.Entry.Path, Length = filestatsize, Type = EventType.FileSkipped }); continue; } @@ -96,13 +96,13 @@ namespace Duplicati.Library.Main.Operation.Backup // If we disable the filetime check, we always assume that the file has changed // Otherwise we check that the timestamps are different or if any of them are empty - var timestampChanged = DISABLEFILETIMECHECK || e.LastWrite != e.OldModified || e.LastWrite.Ticks == 0 || e.OldModified.Ticks == 0; + e.TimestampChanged = DISABLEFILETIMECHECK || e.LastWrite != e.OldModified || e.LastWrite.Ticks == 0 || e.OldModified.Ticks == 0; // Avoid generating a new metadata blob if timestamp has not changed // and we only check for timestamp changes - if (CHECKFILETIMEONLY && !timestampChanged && !isNewFile) + if (CHECKFILETIMEONLY && !e.TimestampChanged && !isNewFile) { - Logging.Log.WriteVerboseMessage(FILELOGTAG, "SkipCheckNoTimestampChange", "Skipped checking file, because timestamp was not updated {0}", e.Path); + Logging.Log.WriteVerboseMessage(FILELOGTAG, "SkipCheckNoTimestampChange", "Skipped checking file, because timestamp was not updated {0}", e.Entry.Path); try { await database.AddUnmodifiedAsync(e.OldId, e.LastWrite); @@ -111,9 +111,9 @@ namespace Duplicati.Library.Main.Operation.Backup { if (ex.IsRetiredException()) throw; - Logging.Log.WriteWarningMessage(FILELOGTAG, "FailedToAddFile", ex, "Failed while attempting to add unmodified file to database: {0}", e.Path); + Logging.Log.WriteWarningMessage(FILELOGTAG, "FailedToAddFile", ex, "Failed while attempting to add unmodified file to database: {0}", e.Entry.Path); } - await self.ProgressChannel.WriteAsync(new ProgressEvent() { Filepath = e.Path, Length = filestatsize, Type = EventType.FileSkipped }); + await self.ProgressChannel.WriteAsync(new ProgressEvent() { Filepath = e.Entry.Path, Length = filestatsize, Type = EventType.FileSkipped }); continue; } @@ -130,28 +130,28 @@ namespace Duplicati.Library.Main.Operation.Backup } // Compute current metadata - e.MetaHashAndSize = SKIPMETADATA ? EMPTY_METADATA : Utility.WrapMetadata(MetadataGenerator.GenerateMetadata(e.Path, e.Attributes, options, snapshot), options); + e.MetaHashAndSize = SKIPMETADATA ? EMPTY_METADATA : Utility.WrapMetadata(MetadataGenerator.GenerateMetadata(e.Entry, e.Attributes, options), options); e.MetadataChanged = !SKIPMETADATA && (e.MetaHashAndSize.Blob.Length != e.OldMetaSize || e.MetaHashAndSize.FileHash != e.OldMetaHash); // Check if the file is new, or something indicates a change var filesizeChanged = filestatsize < 0 || e.LastFileSize < 0 || filestatsize != e.LastFileSize; - if (isNewFile || timestampChanged || filesizeChanged || e.MetadataChanged) + if (isNewFile || e.TimestampChanged || filesizeChanged || e.MetadataChanged) { - Logging.Log.WriteVerboseMessage(FILELOGTAG, "CheckFileForChanges", "Checking file for changes {0}, new: {1}, timestamp changed: {2}, size changed: {3}, metadatachanged: {4}, {5} vs {6}", e.Path, isNewFile, timestampChanged, filesizeChanged, e.MetadataChanged, e.LastWrite, e.OldModified); + Logging.Log.WriteVerboseMessage(FILELOGTAG, "CheckFileForChanges", "Checking file for changes {0}, new: {1}, timestamp changed: {2}, size changed: {3}, metadatachanged: {4}, {5} vs {6}", e.Entry.Path, isNewFile, e.TimestampChanged, filesizeChanged, e.MetadataChanged, e.LastWrite, e.OldModified); await self.Output.WriteAsync(e); } else { - Logging.Log.WriteVerboseMessage(FILELOGTAG, "SkipCheckNoMetadataChange", "Skipped checking file, because no metadata was updated {0}", e.Path); + Logging.Log.WriteVerboseMessage(FILELOGTAG, "SkipCheckNoMetadataChange", "Skipped checking file, because no metadata was updated {0}", e.Entry.Path); try { await database.AddUnmodifiedAsync(e.OldId, e.LastWrite); } catch (Exception ex) { - Logging.Log.WriteWarningMessage(FILELOGTAG, "FailedToAddFile", ex, "Failed while attempting to add unmodified file to database: {0}", e.Path); + Logging.Log.WriteWarningMessage(FILELOGTAG, "FailedToAddFile", ex, "Failed while attempting to add unmodified file to database: {0}", e.Entry.Path); } - await self.ProgressChannel.WriteAsync(new ProgressEvent() { Filepath = e.Path, Length = filestatsize, Type = EventType.FileSkipped }); + await self.ProgressChannel.WriteAsync(new ProgressEvent() { Filepath = e.Entry.Path, Length = filestatsize, Type = EventType.FileSkipped }); } } }); diff --git a/Duplicati/Library/Main/Operation/Backup/MetadataGenerator.cs b/Duplicati/Library/Main/Operation/Backup/MetadataGenerator.cs index adb32616c..6be52d47c 100644 --- a/Duplicati/Library/Main/Operation/Backup/MetadataGenerator.cs +++ b/Duplicati/Library/Main/Operation/Backup/MetadataGenerator.cs @@ -1,30 +1,27 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; -using System.Threading.Tasks; using System.Collections.Generic; -using CoCoL; -using Duplicati.Library.Main.Operation.Common; -using Duplicati.Library.Snapshots; +using Duplicati.Library.Interface; namespace Duplicati.Library.Main.Operation.Backup { @@ -35,7 +32,7 @@ namespace Duplicati.Library.Main.Operation.Backup { private static readonly string METALOGTAG = Logging.Log.LogTagFromType(typeof(MetadataGenerator)) + ".Metadata"; - public static Dictionary GenerateMetadata(string path, System.IO.FileAttributes attributes, Options options, Snapshots.ISnapshotService snapshot) + public static Dictionary GenerateMetadata(ISourceProviderEntry entry, System.IO.FileAttributes attributes, Options options) { try { @@ -43,7 +40,7 @@ namespace Duplicati.Library.Main.Operation.Backup if (!options.SkipMetadata) { - metadata = snapshot.GetMetadata(path, snapshot.IsSymlink(path, attributes), options.SymlinkPolicy == Options.SymlinkStrategy.Follow); + metadata = entry.MinorMetadata; if (metadata == null) metadata = new Dictionary(); @@ -54,11 +51,11 @@ namespace Duplicati.Library.Main.Operation.Backup { try { - metadata["CoreLastWritetime"] = snapshot.GetLastWriteTimeUtc(path).Ticks.ToString(); + metadata["CoreLastWritetime"] = entry.LastModificationUtc.Ticks.ToString(); } catch (Exception ex) { - Logging.Log.WriteWarningMessage(METALOGTAG, "TimestampReadFailed", ex, "Failed to read timestamp on \"{0}\"", path); + Logging.Log.WriteWarningMessage(METALOGTAG, "TimestampReadFailed", ex, "Failed to read timestamp on \"{0}\"", entry.Path); } } @@ -66,11 +63,11 @@ namespace Duplicati.Library.Main.Operation.Backup { try { - metadata["CoreCreatetime"] = snapshot.GetCreationTimeUtc(path).Ticks.ToString(); + metadata["CoreCreatetime"] = entry.CreatedUtc.Ticks.ToString(); } catch (Exception ex) { - Logging.Log.WriteWarningMessage(METALOGTAG, "TimestampReadFailed", ex, "Failed to read timestamp on \"{0}\"", path); + Logging.Log.WriteWarningMessage(METALOGTAG, "TimestampReadFailed", ex, "Failed to read timestamp on \"{0}\"", entry.Path); } } } @@ -81,9 +78,9 @@ namespace Duplicati.Library.Main.Operation.Backup return metadata; } - catch(Exception ex) + catch (Exception ex) { - Logging.Log.WriteWarningMessage(METALOGTAG, "MetadataProcessFailed", ex, "Failed to process metadata for \"{0}\", storing empty metadata", path); + Logging.Log.WriteWarningMessage(METALOGTAG, "MetadataProcessFailed", ex, "Failed to process metadata for \"{0}\", storing empty metadata", entry.Path); return new Dictionary(); } } diff --git a/Duplicati/Library/Main/Operation/Backup/MetadataPreProcess.cs b/Duplicati/Library/Main/Operation/Backup/MetadataPreProcess.cs index b8554d45f..2ae10faa1 100644 --- a/Duplicati/Library/Main/Operation/Backup/MetadataPreProcess.cs +++ b/Duplicati/Library/Main/Operation/Backup/MetadataPreProcess.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -24,11 +24,8 @@ using CoCoL; using System.Threading.Tasks; using System.IO; using System.Collections.Generic; -using System.Security.Principal; -using System.Threading; using Duplicati.Library.Interface; using Duplicati.Library.Main.Operation.Common; -using Duplicati.Library.Snapshots; namespace Duplicati.Library.Main.Operation.Backup { @@ -43,7 +40,7 @@ namespace Duplicati.Library.Main.Operation.Backup public class FileEntry { // From input - public string Path; + public ISourceProviderEntry Entry; // Split public long PathPrefixID; @@ -63,9 +60,10 @@ namespace Duplicati.Library.Main.Operation.Backup // After processing metadata public IMetahash MetaHashAndSize; public bool MetadataChanged; + public bool TimestampChanged; } - public static Task Run(Channels channels, Snapshots.ISnapshotService snapshot, Options options, BackupDatabase database, long lastfilesetid, ITaskReader taskReader) + public static Task Run(Channels channels, Options options, BackupDatabase database, long lastfilesetid, ITaskReader taskReader) { return AutomationExtensions.RunTask(new { @@ -84,37 +82,40 @@ namespace Duplicati.Library.Main.Operation.Backup while (true) { - var path = await self.Input.ReadAsync(); + var entry = await self.Input.ReadAsync(); // We ignore the stop signal, but not the pause and terminate await taskReader.ProgressRendevouz().ConfigureAwait(false); var lastwrite = new DateTime(0, DateTimeKind.Utc); - var attributes = default(FileAttributes); + var attributes = entry.IsFolder + ? FileAttributes.Directory + : FileAttributes.Normal; + try { - lastwrite = snapshot.GetLastWriteTimeUtc(path); + lastwrite = entry.LastModificationUtc; } catch (Exception ex) { - Logging.Log.WriteWarningMessage(FILELOGTAG, "TimestampReadFailed", ex, "Failed to read timestamp on \"{0}\"", path); + Logging.Log.WriteWarningMessage(FILELOGTAG, "TimestampReadFailed", ex, "Failed to read timestamp on \"{0}\"", entry.Path); } try { - attributes = snapshot.GetAttributes(path); + attributes = entry.Attributes; } catch (Exception ex) { - Logging.Log.WriteVerboseMessage(FILELOGTAG, "FailedAttributeRead", "Failed to read attributes from {0}: {1}", path, ex.Message); + Logging.Log.WriteVerboseMessage(FILELOGTAG, "FailedAttributeRead", "Failed to read attributes from {0}: {1}", entry.Path, ex.Message); } // If we only have metadata, stop here - if (await ProcessMetadata(path, attributes, lastwrite, options, snapshot, emptymetadata, database, self.StreamBlockChannel).ConfigureAwait(false)) + if (await ProcessMetadata(entry, attributes, lastwrite, options, emptymetadata, database, self.StreamBlockChannel).ConfigureAwait(false)) { try { - var split = Database.LocalDatabase.SplitIntoPrefixAndName(path); + var split = Database.LocalDatabase.SplitIntoPrefixAndName(entry.Path); long prefixid; if (string.Equals(prevprefix.Key, split.Key, StringComparison.Ordinal)) @@ -131,7 +132,7 @@ namespace Duplicati.Library.Main.Operation.Backup await self.Output.WriteAsync(new FileEntry { OldId = tmp.Item1, - Path = path, + Entry = entry, PathPrefixID = prefixid, Filename = split.Value, Attributes = attributes, @@ -148,7 +149,7 @@ namespace Duplicati.Library.Main.Operation.Backup await self.Output.WriteAsync(new FileEntry { OldId = res == null ? -1 : res.id, - Path = path, + Entry = entry, PathPrefixID = prefixid, Filename = split.Value, Attributes = attributes, @@ -166,7 +167,7 @@ namespace Duplicati.Library.Main.Operation.Backup continue; Logging.Log.WriteWarningMessage(FILELOGTAG, "ProcessingMetadataFailed", ex, - "Failed to process entry, path: {0}", path); + "Failed to process entry, path: {0}", entry.Path); } } } @@ -179,9 +180,9 @@ namespace Duplicati.Library.Main.Operation.Backup /// Processes the metadata for the given path. /// /// True if the path should be submitted to more analysis, false if there is nothing else to do - private static async Task ProcessMetadata(string path, FileAttributes attributes, DateTime lastwrite, Options options, Snapshots.ISnapshotService snapshot, IMetahash emptymetadata, BackupDatabase database, IWriteChannel streamblockchannel) + private static async Task ProcessMetadata(ISourceProviderEntry entry, FileAttributes attributes, DateTime lastwrite, Options options, IMetahash emptymetadata, BackupDatabase database, IWriteChannel streamblockchannel) { - if (snapshot.IsSymlink(path, attributes)) + if (entry.IsSymlink) { // Not all reparse points are symlinks. // For example, on Windows 10 Fall Creator's Update, the OneDrive folder (and all subfolders) @@ -190,57 +191,58 @@ namespace Duplicati.Library.Main.Operation.Backup string symlinkTarget = null; try { - symlinkTarget = snapshot.GetSymlinkTarget(path); + symlinkTarget = entry.SymlinkTarget; } catch (Exception ex) { - Logging.Log.WriteExplicitMessage(FILELOGTAG, "SymlinkTargetReadFailure", ex, "Failed to read symlink target for path: {0}", path); + Logging.Log.WriteExplicitMessage(FILELOGTAG, "SymlinkTargetReadFailure", ex, "Failed to read symlink target for path: {0}", entry.Path); } if (!string.IsNullOrWhiteSpace(symlinkTarget)) { if (options.SymlinkPolicy == Options.SymlinkStrategy.Ignore) { - Logging.Log.WriteVerboseMessage(FILELOGTAG, "IgnoreSymlink", "Ignoring symlink {0}", path); + Logging.Log.WriteVerboseMessage(FILELOGTAG, "IgnoreSymlink", "Ignoring symlink {0}", entry.Path); return false; } if (options.SymlinkPolicy == Options.SymlinkStrategy.Store) { - var metadata = MetadataGenerator.GenerateMetadata(path, attributes, options, snapshot); + var metadata = MetadataGenerator.GenerateMetadata(entry, attributes, options); if (!metadata.ContainsKey("CoreSymlinkTarget")) metadata["CoreSymlinkTarget"] = symlinkTarget; var metahash = Utility.WrapMetadata(metadata, options); - await AddSymlinkToOutputAsync(path, DateTime.UtcNow, metahash, database, streamblockchannel).ConfigureAwait(false); + await AddSymlinkToOutputAsync(entry.Path, DateTime.UtcNow, metahash, database, streamblockchannel).ConfigureAwait(false); - Logging.Log.WriteVerboseMessage(FILELOGTAG, "StoreSymlink", "Stored symlink {0}", path); + Logging.Log.WriteVerboseMessage(FILELOGTAG, "StoreSymlink", "Stored symlink {0}", entry.Path); // Don't process further return false; } } else { - Logging.Log.WriteVerboseMessage(FILELOGTAG, "FollowingEmptySymlink", "Treating empty symlink as regular path {0}", path); + Logging.Log.WriteVerboseMessage(FILELOGTAG, "FollowingEmptySymlink", "Treating empty symlink as regular path {0}", entry.Path); } } + if ((attributes & FileAttributes.Directory) == FileAttributes.Directory) { IMetahash metahash; if (!options.SkipMetadata) { - metahash = Utility.WrapMetadata(MetadataGenerator.GenerateMetadata(path, attributes, options, snapshot), options); + metahash = Utility.WrapMetadata(MetadataGenerator.GenerateMetadata(entry, attributes, options), options); } else { metahash = emptymetadata; } - Logging.Log.WriteVerboseMessage(FILELOGTAG, "AddDirectory", "Adding directory {0}", path); - await AddFolderToOutputAsync(path, lastwrite, metahash, database, streamblockchannel).ConfigureAwait(false); + Logging.Log.WriteVerboseMessage(FILELOGTAG, "AddDirectory", "Adding directory {0}", entry.Path); + await AddFolderToOutputAsync(entry.Path, lastwrite, metahash, database, streamblockchannel).ConfigureAwait(false); return false; } diff --git a/Duplicati/Library/Main/Operation/Backup/UploadRealFilelist.cs b/Duplicati/Library/Main/Operation/Backup/UploadRealFilelist.cs index 284884cf4..3d8431f62 100644 --- a/Duplicati/Library/Main/Operation/Backup/UploadRealFilelist.cs +++ b/Duplicati/Library/Main/Operation/Backup/UploadRealFilelist.cs @@ -32,7 +32,7 @@ internal static class UploadRealFilelist /// private static readonly string LOGTAG = Logging.Log.LogTagFromType(typeof(UploadRealFilelist)); - public static async Task Run(BackupResults result, BackupDatabase db, IBackendManager backendManager, Options options, FilesetVolumeWriter filesetvolume, long filesetid, Common.ITaskReader taskreader) + public static async Task Run(BackupResults result, BackupDatabase db, IBackendManager backendManager, Options options, FilesetVolumeWriter filesetvolume, long filesetid, Common.ITaskReader taskreader, bool lastWasPartial) { // We ignore the stop signal, but not the pause and terminate await taskreader.ProgressRendevouz().ConfigureAwait(false); @@ -47,7 +47,7 @@ internal static class UploadRealFilelist result.AddedSymlinks + result.ModifiedSymlinks + result.DeletedSymlinks; //Changes in the filelist triggers a filelist upload - if (options.UploadUnchangedBackups || changeCount > 0) + if (options.UploadUnchangedBackups || changeCount > 0 || lastWasPartial) { using (new Logging.Timer(LOGTAG, "UploadNewFileset", "Uploading a new fileset")) { @@ -72,6 +72,12 @@ internal static class UploadRealFilelist } else { + if (result.TimestampChangedFiles != 0) + { + Logging.Log.WriteInformationMessage(LOGTAG, "DetectedTimestampChanges", "Detected timestamp changes, but no data needs to be uploaded, pushing timestamp changes to the latest fileset"); + await db.PushTimestampChangesToPreviousVersionAsync(filesetid).ConfigureAwait(false); + } + Logging.Log.WriteVerboseMessage(LOGTAG, "RemovingLeftoverTempFile", "removing temp files, as no data needs to be uploaded"); await db.RemoveRemoteVolumeAsync(filesetvolume.RemoteFilename); } diff --git a/Duplicati/Library/Main/Operation/Backup/UploadSyntheticFilelist.cs b/Duplicati/Library/Main/Operation/Backup/UploadSyntheticFilelist.cs index e788e0469..7efa1b13e 100644 --- a/Duplicati/Library/Main/Operation/Backup/UploadSyntheticFilelist.cs +++ b/Duplicati/Library/Main/Operation/Backup/UploadSyntheticFilelist.cs @@ -19,6 +19,7 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +using Duplicati.Library.Main.Database; using Duplicati.Library.Main.Operation.Common; using Duplicati.Library.Main.Volumes; using System; @@ -37,27 +38,26 @@ namespace Duplicati.Library.Main.Operation.Backup /// private static readonly string LOGTAG = Logging.Log.LogTagFromType(typeof(UploadSyntheticFilelist)); - public static async Task Run(BackupDatabase database, Options options, BackupResults result, ITaskReader taskreader, IBackendManager backendManager, string lastTempFilelist, long lastTempFilesetId) + /// + /// This method is used to generate and upload a synthetic file list, if needed + /// + /// The database to use + /// The options to use + /// The backup results + /// The task reader to use + /// The backend manager to use + /// The last temporary file list volume + /// + public static async Task Run(BackupDatabase database, Options options, BasicResults result, ITaskReader taskreader, IBackendManager backendManager, RemoteVolumeEntry lastTempFilelist) { // Check if we should upload a synthetic filelist - if (options.DisableSyntheticFilelist || string.IsNullOrWhiteSpace(lastTempFilelist) || lastTempFilesetId <= 0) + if (options.DisableSyntheticFilelist || string.IsNullOrWhiteSpace(lastTempFilelist.Name) || lastTempFilelist.ID <= 0) return; - // Check that we still need to process this after the cleanup has performed its duties - var syntbase = await database.GetRemoteVolumeFromFilesetIDAsync(lastTempFilesetId); - - // If we do not have a valid entry, warn and quit - if (syntbase.Name == null) - { - // TODO: If the repair succeeds, this could give a false warning? - Logging.Log.WriteWarningMessage(LOGTAG, "MissingTemporaryFilelist", null, "Expected there to be a temporary fileset for synthetic filelist ({0}, {1}), but none was found?", lastTempFilesetId, lastTempFilelist); - return; - } - // Files is missing or repaired - if (syntbase.State != RemoteVolumeState.Uploading && syntbase.State != RemoteVolumeState.Temporary) + if (lastTempFilelist.State != RemoteVolumeState.Uploading && lastTempFilelist.State != RemoteVolumeState.Temporary) { - Logging.Log.WriteInformationMessage(LOGTAG, "SkippingSyntheticListUpload", "Skipping synthetic upload because temporary fileset appers to be complete: ({0}, {1}, {2})", lastTempFilesetId, lastTempFilelist, syntbase.State); + Logging.Log.WriteInformationMessage(LOGTAG, "SkippingSyntheticListUpload", "Skipping synthetic upload because temporary fileset appers to be complete: ({0}, {1}, {2})", lastTempFilelist.ID, lastTempFilelist.Name, lastTempFilelist.State); return; } @@ -66,9 +66,7 @@ namespace Duplicati.Library.Main.Operation.Backup var incompleteFilesets = (await database.GetIncompleteFilesetsAsync()).OrderBy(x => x.Value).ToList(); if (!incompleteFilesets.Any()) - { return; - } if (!await taskreader.ProgressRendevouz().ConfigureAwait(false)) return; @@ -77,34 +75,20 @@ namespace Duplicati.Library.Main.Operation.Backup Logging.Log.WriteInformationMessage(LOGTAG, "PreviousBackupFilelistUpload", "Uploading filelist from previous interrupted backup"); var incompleteSet = incompleteFilesets.Last(); - var badIds = from n in incompleteFilesets select n.Key; + var badIds = incompleteFilesets.Select(n => n.Key); - var prevs = (from n in await database.GetFilesetTimesAsync() - where - n.Key < incompleteSet.Key - && - !badIds.Contains(n.Key) - orderby n.Key - select n.Key).ToArray(); + var prevs = (await database.GetFilesetTimesAsync()) + .Where(n => n.Key < incompleteSet.Key && !badIds.Contains(n.Key)) + .OrderBy(n => n.Key) + .Select(n => n.Key) + .ToArray(); var prevId = prevs.Length == 0 ? -1 : prevs.Last(); FilesetVolumeWriter fsw = null; try { - var s = 1; - var fileTime = incompleteSet.Value + TimeSpan.FromSeconds(s); - - // Probe for an unused filename - while (s < 60) - { - var id = await database.GetRemoteVolumeIDAsync(VolumeBase.GenerateFilename(RemoteVolumeType.Files, options, null, fileTime)); - if (id < 0) - break; - - fileTime = incompleteSet.Value + TimeSpan.FromSeconds(++s); - } - + var fileTime = await FilesetVolumeWriter.ProbeUnusedFilenameName(database, options, incompleteSet.Value).ConfigureAwait(false); fsw = new FilesetVolumeWriter(options, fileTime); fsw.VolumeID = await database.RegisterRemoteVolumeAsync(fsw.RemoteFilename, RemoteVolumeType.Files, RemoteVolumeState.Temporary); @@ -126,6 +110,10 @@ namespace Duplicati.Library.Main.Operation.Backup return; await database.UpdateRemoteVolumeAsync(fsw.RemoteFilename, RemoteVolumeState.Uploading, -1, null); + // If the previous filelist was not uploaded, we register it for deletion, as we have created a new synthetic one + // Because it is registered as "Deleting", it will be removed from remote storage by the cleanup process if it exists + if (!string.IsNullOrWhiteSpace(lastTempFilelist.Name) && (lastTempFilelist.State == RemoteVolumeState.Uploading || lastTempFilelist.State == RemoteVolumeState.Temporary)) + await database.UpdateRemoteVolumeAsync(lastTempFilelist.Name, RemoteVolumeState.Deleting, -1, null); await database.CommitTransactionAsync("CommitUpdateFilelistVolume"); await backendManager.PutAsync(fsw, null, null, false, taskreader.ProgressToken); diff --git a/Duplicati/Library/Main/Operation/BackupHandler.cs b/Duplicati/Library/Main/Operation/BackupHandler.cs index 258119897..01af2633c 100644 --- a/Duplicati/Library/Main/Operation/BackupHandler.cs +++ b/Duplicati/Library/Main/Operation/BackupHandler.cs @@ -34,6 +34,11 @@ using Duplicati.Library.Common.IO; using Duplicati.Library.Logging; using Duplicati.Library.Main.Operation.Common; using System.Data; +using Duplicati.Library.DynamicLoader; +using Duplicati.Library.SourceProvider; +using Duplicati.Library.Snapshots.USN; +using System.Text.RegularExpressions; +using System.IO; namespace Duplicati.Library.Main.Operation { @@ -47,80 +52,164 @@ namespace Duplicati.Library.Main.Operation /// /// The tag used for logging /// - private static readonly string LOGTAG = Logging.Log.LogTagFromType(); + private static readonly string LOGTAG = Log.LogTagFromType(); private readonly Options m_options; - private readonly string m_backendurl; private LocalBackupDatabase m_database; - private System.Data.IDbTransaction m_transaction; - - private Library.Utility.IFilter m_filter; - private Library.Utility.IFilter m_sourceFilter; + private IFilter m_filter; private readonly BackupResults m_result; private readonly ITaskReader m_taskReader; public readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); - public BackupHandler(string backendurl, Options options, BackupResults results) + public BackupHandler(Options options, BackupResults results) { m_options = options; m_result = results; - m_backendurl = backendurl; m_taskReader = results.TaskControl; if (options.AllowPassphraseChange) throw new UserInformationException(Strings.Common.PassphraseChangeUnsupported, "PassphraseChangeUnsupported"); } - public static Snapshots.ISnapshotService GetSnapshot(string[] sources, Options options) + /// + /// Gets a single source provider for the given sources + /// + /// The sources to get providers for + /// The options to use + /// The source providers + public static async Task GetSourceProvider(IEnumerable sources, Options options, CancellationToken cancellationToken) + => Combiner.Combine(await GetSourceProviders(sources, options, cancellationToken)); + + /// + /// Gets a snapshot service for the given sources + /// + /// The sources to get the snapshot for + /// The options to use + /// The source provider + private static ISnapshotService GetFileSnapshotService(IEnumerable sources, Options options) { try { if (options.SnapShotStrategy != Options.OptimizationStrategy.Off) - return Duplicati.Library.Snapshots.SnapshotUtility.CreateSnapshot(sources, options.RawOptions); + return SnapshotUtility.CreateSnapshot(sources, options.RawOptions, options.SymlinkPolicy == Options.SymlinkStrategy.Follow); } catch (Exception ex) { if (options.SnapShotStrategy == Options.OptimizationStrategy.Required) throw new UserInformationException(Strings.Common.SnapshotFailedError(ex.Message), "SnapshotFailed", ex); else if (options.SnapShotStrategy == Options.OptimizationStrategy.On) - Logging.Log.WriteWarningMessage(LOGTAG, "SnapshotFailed", ex, Strings.Common.SnapshotFailedError(ex.Message)); + Log.WriteWarningMessage(LOGTAG, "SnapshotFailed", ex, Strings.Common.SnapshotFailedError(ex.Message)); else if (options.SnapShotStrategy == Options.OptimizationStrategy.Auto) - Logging.Log.WriteInformationMessage(LOGTAG, "SnapshotFailed", Strings.Common.SnapshotFailedError(ex.Message)); + Log.WriteInformationMessage(LOGTAG, "SnapshotFailed", Strings.Common.SnapshotFailedError(ex.Message)); } - if (OperatingSystem.IsMacOS() || OperatingSystem.IsLinux()) - { - return new NoSnapshotLinux(options.IgnoreAdvisoryLocking); - } - else if (OperatingSystem.IsWindows()) - { - return new NoSnapshotWindows(); - } - else - { - throw new NotSupportedException("Unsupported Operating System"); - } + return SnapshotUtility.CreateNoSnapshot(sources, options.IgnoreAdvisoryLocking, options.SymlinkPolicy == Options.SymlinkStrategy.Follow); } /// - /// Create instance of USN journal service + /// Gets all source providers for the given sources /// - /// - /// - /// - /// - /// - private UsnJournalService GetJournalService(IEnumerable sources, ISnapshotService snapshot, IFilter filter, long lastfilesetid) + /// The sources to get providers for + /// The options to use + /// The filter to use + /// The last fileset id + /// The source providers + private static async Task> GetSourceProviders(IEnumerable sources, Options options, CancellationToken cancellationToken) + { + // Group the sources by their type, so we can combine all snapshot paths into a single snapshot + var sourceTypes = sources.GroupBy(x => x.StartsWith("@") ? "@" : Library.Utility.Utility.GuessScheme(x) ?? "file", StringComparer.OrdinalIgnoreCase); + + // To avoid leaking snapshot instances, we create all instances first and then dispose them if an exception occurs + // The number of instances is expected to be low, so the memory overhead is acceptable + var results = new List(); + try + { + foreach (var entry in sourceTypes) + { + if ("file".Equals(entry.Key, StringComparison.OrdinalIgnoreCase)) + results.Add(new LocalFileSource(GetFileSnapshotService(entry, options))); + else if ("vss".Equals(entry.Key, StringComparison.OrdinalIgnoreCase) || "lvm".Equals(entry.Key, StringComparison.OrdinalIgnoreCase)) + results.Add(new LocalFileSource(SnapshotUtility.CreateSnapshot(entry, options.RawOptions, options.SymlinkPolicy == Options.SymlinkStrategy.Follow))); + else if ("@".Equals(entry.Key, StringComparison.OrdinalIgnoreCase)) + { + foreach (var url in entry) + { + var sanitizedUrl = Library.Utility.Utility.GetUrlWithoutCredentials(url); + var m = Regex.Match(url, @"^@(?[^|]+)\|(?.+)$", RegexOptions.IgnoreCase); + if (m.Success) + { + var mountpoint = m.Groups["mountpoint"].Value; + + if (mountpoint.Any(x => Path.GetInvalidPathChars().Contains(x))) + throw new UserInformationException(string.Format("The mountpoint \"{0}\" contains invalid characters", mountpoint), "InvalidMountpoint"); + if (!Path.IsPathRooted(mountpoint)) + throw new UserInformationException(string.Format("The mountpoint \"{0}\" is not a valid rooted mountpoint", mountpoint), "InvalidMountpoint"); + + var backendurl = m.Groups["url"].Value; + + ISourceProvider provider; + try + { + provider = await SourceProviderLoader.GetSourceProvider(backendurl, Path.GetFullPath(mountpoint), options.RawOptions, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + if (options.AllowMissingSource) + { + Log.WriteWarningMessage(LOGTAG, "SourceProviderFailed", ex, "Failed to load source provider for \"{0}\"", sanitizedUrl); + continue; + } + + throw new UserInformationException($"Failed to load source provider for \"{sanitizedUrl}\": {ex.Message}", "SourceProviderFailed", ex); + } + + // Don't accept missing providers + results.Add(provider ?? throw new UserInformationException($"The source \"{sanitizedUrl}\" is not supported", "SourceNotSupported")); + } + else + throw new UserInformationException($"The source \"{sanitizedUrl}\" is not a supported format", "SourceFormatNotSupported"); + } + } + else + throw new UserInformationException($"The source type \"{entry.Key}\" is not supported", "SourceTypeNotSupported"); + } + } + catch + { + foreach (var provider in results) + (provider as IDisposable)?.Dispose(); + + throw; + } + + if (results.Count == 0) + throw new UserInformationException("No sources were available for the backup", "NoSourcesAvailable"); + + return results; + } + + public UsnJournalService GetJournalService(ISourceProvider provider, IFilter filter, long lastfilesetid) { if (m_options.UsnStrategy == Options.OptimizationStrategy.Off) return null; if (!OperatingSystem.IsWindows()) throw new UserInformationException("USN journal is only supported on Windows", "UsnJournalNotSupported"); + var providers = (provider is Combiner c ? c.Providers : [provider]) + .Select((x, i) => new { Provider = x, Index = i }) + .Where(x => x.Provider is LocalFileSource) + .ToList(); + + if (providers.Count == 0) + return null; + if (providers.Count > 1) + throw new UserInformationException("Multiple USN journal services are not supported", "MultipleUSNJournalServices"); + var fileProvider = providers.First().Provider as LocalFileSource; + var journalData = m_database.GetChangeJournalData(lastfilesetid); - var service = new UsnJournalService(sources, snapshot, filter, m_options.FileAttributeFilter, m_options.SkipFilesLargerThan, + var service = new UsnJournalService(fileProvider.SnapshotService, filter, m_options.FileAttributeFilter, m_options.SkipFilesLargerThan, journalData, cancellationTokenSource.Token); foreach (var volumeData in service.VolumeDataList) @@ -174,8 +263,7 @@ namespace Duplicati.Library.Main.Operation private sealed record PreBackupVerifyResult( LocalBackupDatabase Database, - string LastTempFilelist, - long LastTempFilesetId + RemoteVolumeEntry LastTempFilelist ); /// @@ -200,8 +288,7 @@ namespace Duplicati.Library.Main.Operation LocalBackupDatabase database = null; // If we have an interrupted backup, grab the fileset - string lastTempFilelist = null; - long lastTempFilesetId = -1; + RemoteVolumeEntry lastTempFilelist = default; using (new Logging.Timer(LOGTAG, "PreBackupVerify", "PreBackupVerify")) { @@ -230,15 +317,9 @@ namespace Duplicati.Library.Main.Operation // Make sure the database is sane await db.VerifyConsistencyAsync(options.Blocksize, options.BlockhashSize, !options.DisableFilelistConsistencyChecks); + // Guard the last filelist if (!options.DisableSyntheticFilelist) - { - var candidates = (await db.GetIncompleteFilesetsAsync()).OrderBy(x => x.Value).ToArray(); - if (candidates.Any()) - { - lastTempFilesetId = candidates.Last().Key; - lastTempFilelist = database.GetRemoteVolumeFromFilesetID(lastTempFilesetId).Name; - } - } + lastTempFilelist = database.GetLastIncompleteFilesetVolume(null); } try @@ -249,7 +330,9 @@ namespace Duplicati.Library.Main.Operation await UpdateStorageStatsFromDatabase(result, database, options, backendManager, result.TaskControl.ProgressToken).ConfigureAwait(false); } else - await FilelistProcessor.VerifyRemoteList(backendManager, options, database, result.BackendWriter, new string[] { lastTempFilelist }, logErrors: false).ConfigureAwait(false); + { + await FilelistProcessor.VerifyRemoteList(backendManager, options, database, result.BackendWriter, [lastTempFilelist.Name], [], logErrors: false, verifyMode: FilelistProcessor.VerifyMode.VerifyAndClean).ConfigureAwait(false); + } } catch (RemoteListVerificationException ex) { @@ -261,20 +344,22 @@ namespace Duplicati.Library.Main.Operation database = null; result.SetDatabase(null); - new RepairHandler(options, (RepairResults)result.RepairResults).Run(backendManager, null); + await new RepairHandler(options, (RepairResults)result.RepairResults) + .RunAsync(backendManager, null) + .ConfigureAwait(false); // Re-open the database and backend manager database = new LocalBackupDatabase(options.Dbpath, options); result.SetDatabase(database); Log.WriteInformationMessage(LOGTAG, "BackendCleanupFinished", "Backend cleanup finished, retrying verification"); - await FilelistProcessor.VerifyRemoteList(backendManager, options, database, result.BackendWriter, new string[] { lastTempFilelist }).ConfigureAwait(false); + await FilelistProcessor.VerifyRemoteList(backendManager, options, database, result.BackendWriter, [lastTempFilelist.Name], [], logErrors: true, verifyMode: FilelistProcessor.VerifyMode.VerifyStrict).ConfigureAwait(false); } else throw; } - return new PreBackupVerifyResult(database, lastTempFilelist, lastTempFilesetId); + return new PreBackupVerifyResult(database, lastTempFilelist); } catch { @@ -288,7 +373,7 @@ namespace Duplicati.Library.Main.Operation /// /// Performs the bulk of work by starting all relevant processes /// - private static async Task RunMainOperation(Backup.Channels channels, IEnumerable sources, Snapshots.ISnapshotService snapshot, UsnJournalService journalService, Backup.BackupDatabase database, IBackendManager backendManager, Backup.BackupStatsCollector stats, Options options, IFilter sourcefilter, IFilter filter, BackupResults result, ITaskReader taskreader, long filesetid, long lastfilesetid) + private static async Task RunMainOperation(Backup.Channels channels, ISourceProvider source, UsnJournalService journalService, Backup.BackupDatabase database, IBackendManager backendManager, Backup.BackupStatsCollector stats, Options options, IFilter filter, BackupResults result, ITaskReader taskreader, long filesetid, long lastfilesetid) { using (new Logging.Timer(LOGTAG, "BackupMainOperation", "BackupMainOperation")) { @@ -299,15 +384,15 @@ namespace Duplicati.Library.Main.Operation new[] { Backup.DataBlockProcessor.Run(channels, database, backendManager, options, taskreader), - Backup.FileBlockProcessor.Run(channels, snapshot, options, database, stats, taskreader), + Backup.FileBlockProcessor.Run(channels, options, database, stats, taskreader), Backup.StreamBlockSplitter.Run(channels, options, database, taskreader), - Backup.FileEnumerationProcess.Run(channels, sources, snapshot, journalService, - options.FileAttributeFilter, sourcefilter, filter, options.SymlinkPolicy, + Backup.FileEnumerationProcess.Run(channels, source, journalService, + options.FileAttributeFilter, filter, options.SymlinkPolicy, options.HardlinkPolicy, options.ExcludeEmptyFolders, options.IgnoreFilenames, GetBlacklistedPaths(options), options.ChangedFilelist, taskreader, () => result.PartialBackup = true, CancellationToken.None), - Backup.FilePreFilterProcess.Run(channels, snapshot, options, stats, database), - Backup.MetadataPreProcess.Run(channels, snapshot, options, database, lastfilesetid, taskreader), + Backup.FilePreFilterProcess.Run(channels, options, stats, database), + Backup.MetadataPreProcess.Run(channels, options, database, lastfilesetid, taskreader), Backup.SpillCollectorProcess.Run(channels, options, database, backendManager, taskreader), Backup.ProgressHandler.Run(channels, result) } @@ -324,7 +409,7 @@ namespace Duplicati.Library.Main.Operation // Spawn additional file processors .Concat( Enumerable.Range(0, options.ConcurrencyFileprocessors - 1).Select(x => - Backup.FileBlockProcessor.Run(channels, snapshot, options, database, stats, taskreader) + Backup.FileBlockProcessor.Run(channels, options, database, stats, taskreader) ) ) ); @@ -351,9 +436,8 @@ namespace Duplicati.Library.Main.Operation return true; if (fileSize >= 0) - { stats.AddExaminedFile(fileSize); - } + return false; }); @@ -385,7 +469,7 @@ namespace Duplicati.Library.Main.Operation } } - private async Task CompactIfRequired(IBackendManager backendManager, long lastVolumeSize) + private async Task CompactIfRequired(IBackendManager backendManager, ReusableTransaction transaction, long lastVolumeSize) { var currentIsSmall = lastVolumeSize != -1 && lastVolumeSize <= m_options.SmallFileSize; @@ -394,7 +478,9 @@ namespace Duplicati.Library.Main.Operation m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Backup_Delete); m_result.DeleteResults = new DeleteResults(m_result); using (var db = new LocalDeleteDatabase(m_database)) - new DeleteHandler(m_options, (DeleteResults)m_result.DeleteResults).DoRun(db, ref m_transaction, true, currentIsSmall, backendManager); + await new DeleteHandler(m_options, (DeleteResults)m_result.DeleteResults) + .DoRunAsync(db, transaction, true, currentIsSmall, backendManager) + .ConfigureAwait(false); } else if (currentIsSmall && !m_options.NoAutoCompact) @@ -402,21 +488,26 @@ namespace Duplicati.Library.Main.Operation m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Backup_Compact); m_result.CompactResults = new CompactResults(m_result); using (var db = new LocalDeleteDatabase(m_database)) - { - (var _, var tr) = await new CompactHandler(m_options, (CompactResults)m_result.CompactResults).DoCompact(db, true, m_transaction, backendManager); - m_transaction = tr; - } + await new CompactHandler(m_options, (CompactResults)m_result.CompactResults) + .DoCompactAsync(db, true, transaction, backendManager) + .ConfigureAwait(false); } } - private async Task PostBackupVerification(string currentFilelistVolume, IBackendManager backendManager) + private async Task PostBackupVerification(string currentFilelistVolume, string previousTemporaryFilelist, IBackendManager backendManager, ReusableTransaction rtr) { m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Backup_PostBackupVerify); using (new Logging.Timer(LOGTAG, "AfterBackupVerify", "AfterBackupVerify")) - await FilelistProcessor.VerifyRemoteList(backendManager, m_options, m_database, m_result.BackendWriter, new string[] { currentFilelistVolume }).ConfigureAwait(false); - await backendManager.WaitForEmptyAsync(m_database, m_transaction, m_taskReader.ProgressToken); + await FilelistProcessor.VerifyRemoteList(backendManager, m_options, m_database, m_result.BackendWriter, [currentFilelistVolume], [previousTemporaryFilelist], logErrors: true, verifyMode: FilelistProcessor.VerifyMode.VerifyStrict).ConfigureAwait(false); + await backendManager.WaitForEmptyAsync(m_database, rtr.Transaction, m_taskReader.ProgressToken); + + // Calculate the number of samples to test, using the largest number of file of a given type + long remoteVolumeCount = m_database.GetRemoteVolumes() + .Where(x => x.State == RemoteVolumeState.Verified) + .GroupBy(x => x.Type) + .Select(x => x.LongCount()) + .Max(); - long remoteVolumeCount = m_database.GetRemoteVolumes().LongCount(x => x.State == RemoteVolumeState.Verified); long samplesToTest = Math.Max(m_options.BackupTestSampleCount, (long)Math.Round(remoteVolumeCount * (m_options.BackupTestPercentage / 100m), MidpointRounding.AwayFromZero)); if (samplesToTest > 0 && remoteVolumeCount > 0) { @@ -425,7 +516,8 @@ namespace Duplicati.Library.Main.Operation using (var testdb = new LocalTestDatabase(m_database)) await new TestHandler(m_options, (TestResults)m_result.TestResults) - .DoRun(samplesToTest, testdb, backendManager).ConfigureAwait(false); + .DoRunAsync(samplesToTest, testdb, backendManager) + .ConfigureAwait(false); } } @@ -437,6 +529,7 @@ namespace Duplicati.Library.Main.Operation if (result.BackendWriter != null) { result.BackendWriter.KnownFileCount = database.GetRemoteVolumes().Count(); + result.BackendWriter.KnownFilesets = database.GetRemoteVolumes().Count(x => x.Type == RemoteVolumeType.Files); result.BackendWriter.KnownFileSize = database.GetRemoteVolumes().Select(x => Math.Max(0, x.Size)).Sum(); result.BackendWriter.UnknownFileCount = 0; @@ -503,23 +596,32 @@ namespace Duplicati.Library.Main.Operation return -1; } - public async Task RunAsync(string[] sources, IBackendManager backendManager, Library.Utility.IFilter filter) + public async Task RunAsync(string[] sources, IBackendManager backendManager, IFilter filter) { m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Backup_Begin); // Do a remote verification, unless disabled - var (database, lastTempFilelist, lastTempFilesetId) = await PreBackupVerify(m_options, m_result, backendManager); + var (database, lastTempFilelist) = await PreBackupVerify(m_options, m_result, backendManager); + var lastTempVolumeIncomplete = false; + + if (!string.IsNullOrWhiteSpace(lastTempFilelist.Name)) + { + if (lastTempFilelist.State == RemoteVolumeState.Temporary || lastTempFilelist.State == RemoteVolumeState.Uploading) + lastTempVolumeIncomplete = true; + } Backup.Channels channels = new(); // If there is no filter, we set an empty filter to simplify the code - // If there is a filter, we make sure that the sources are included m_filter = filter ?? new Library.Utility.FilterExpression(); - m_sourceFilter = new Library.Utility.FilterExpression(sources, true); Task parallelScanner = null; try { + // If we crash now, there is a chance we leave behind partial remote files + if (!m_options.Dryrun) + database.TerminatedWithActiveUploads = true; + using (m_database = database) using (var db = new Backup.BackupDatabase(m_database, m_options)) // Setup runners and instances here @@ -528,12 +630,12 @@ namespace Duplicati.Library.Main.Operation { long filesetid; using var counterToken = CancellationTokenSource.CreateLinkedTokenSource(m_taskReader.ProgressToken); - using (var snapshot = GetSnapshot(sources, m_options)) + using (var source = await GetSourceProvider(sources, m_options, m_taskReader.ProgressToken).ConfigureAwait(false)) { try { // If the previous backup was interrupted, send a synthetic list - await Backup.UploadSyntheticFilelist.Run(db, m_options, m_result, m_result.TaskControl, backendManager, lastTempFilelist, lastTempFilesetId); + await Backup.UploadSyntheticFilelist.Run(db, m_options, m_result, m_result.TaskControl, backendManager, lastTempFilelist); // Grab the previous backup ID, if any var prevfileset = m_database.FilesetTimes.FirstOrDefault(); @@ -558,8 +660,7 @@ namespace Duplicati.Library.Main.Operation var filesetvolumeid = await db.RegisterRemoteVolumeAsync(filesetvolume.RemoteFilename, RemoteVolumeType.Files, RemoteVolumeState.Temporary); filesetid = await db.CreateFilesetAsync(filesetvolumeid, VolumeBase.ParseFilename(filesetvolume.RemoteFilename).Time); - // create USN-based scanner if enabled - var journalService = GetJournalService(sources, snapshot, filter, lastfilesetid); + var journalService = GetJournalService(source, filter, lastfilesetid); // Start parallel scan, or use the database if (m_options.DisableFileScanner) @@ -569,13 +670,13 @@ namespace Duplicati.Library.Main.Operation } else { - parallelScanner = Backup.CountFilesHandler.Run(sources, snapshot, journalService, m_result, m_options, m_sourceFilter, m_filter, GetBlacklistedPaths(), m_result.TaskControl, counterToken.Token); + parallelScanner = Backup.CountFilesHandler.Run(source, journalService, m_result, m_options, m_filter, GetBlacklistedPaths(), m_result.TaskControl, counterToken.Token); } // Run the backup operation if (await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) { - await RunMainOperation(channels, sources, snapshot, journalService, db, backendManager, stats, m_options, m_sourceFilter, m_filter, m_result, m_result.TaskControl, filesetid, lastfilesetid).ConfigureAwait(false); + await RunMainOperation(channels, source, journalService, db, backendManager, stats, m_options, m_filter, m_result, m_result.TaskControl, filesetid, lastfilesetid).ConfigureAwait(false); } } finally @@ -593,68 +694,44 @@ namespace Duplicati.Library.Main.Operation await db.VerifyConsistencyAsync(m_options.Blocksize, m_options.BlockhashSize, false); // Send the actual filelist - await Backup.UploadRealFilelist.Run(m_result, db, backendManager, m_options, filesetvolume, filesetid, m_result.TaskControl); + await Backup.UploadRealFilelist.Run(m_result, db, backendManager, m_options, filesetvolume, filesetid, m_result.TaskControl, lastTempVolumeIncomplete); // Wait for upload completion m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Backup_WaitForUpload); - var lastVolumeSize = await FlushBackend(m_database, m_transaction, m_result, backendManager).ConfigureAwait(false); + var lastVolumeSize = await FlushBackend(m_database, null, m_result, backendManager).ConfigureAwait(false); + if (!m_options.Dryrun) + database.TerminatedWithActiveUploads = false; // Make sure we have the database up-to-date await db.CommitTransactionAsync("CommitAfterUpload", false); - // TODO: Remove this later - m_transaction = m_database.BeginTransaction(); - - try + using (var rtr = new ReusableTransaction(m_database)) { // If this throws, we should roll back the transaction if (await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) - await CompactIfRequired(backendManager, lastVolumeSize); + await CompactIfRequired(backendManager, rtr, lastVolumeSize); if (m_options.UploadVerificationFile && await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) { m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Backup_VerificationUpload); - await FilelistProcessor.UploadVerificationFile(backendManager, m_options, m_database, m_transaction); + await FilelistProcessor.UploadVerificationFile(backendManager, m_options, m_database, rtr.Transaction); } - } - catch - { - try + + if (!m_options.Dryrun) { - m_transaction.Rollback(); - m_transaction.Dispose(); - m_transaction = null; - } - catch (Exception ex) - { - Logging.Log.WriteErrorMessage(LOGTAG, "RollbackError", ex, "Rollback error: {0}", ex.Message); - } - - // Re-throw the original exception - throw; - } - - if (m_options.Dryrun) - { - m_transaction.Rollback(); - m_transaction = null; - } - else - { - using (new Logging.Timer(LOGTAG, "CommitFinalizingBackup", "CommitFinalizingBackup")) - m_transaction.Commit(); - - m_transaction = null; - - if (await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) - { - if (m_options.NoBackendverification) - await UpdateStorageStatsFromDatabase(m_result, m_database, m_options, backendManager, m_taskReader.ProgressToken).ConfigureAwait(false); - else - await PostBackupVerification(filesetvolume.RemoteFilename, backendManager).ConfigureAwait(false); + rtr.Commit("CommitFinalizingBackup"); + if (await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) + { + if (m_options.NoBackendverification) + await UpdateStorageStatsFromDatabase(m_result, m_database, m_options, backendManager, m_taskReader.ProgressToken).ConfigureAwait(false); + else + await PostBackupVerification(filesetvolume.RemoteFilename, lastTempFilelist.Name, backendManager, rtr).ConfigureAwait(false); + } + rtr.Commit("CommitAfterVerification", restart: false); } } + m_database.WriteResults(); m_database.PurgeLogData(m_options.LogRetention); m_database.PurgeDeletedVolumes(DateTime.UtcNow); @@ -662,7 +739,8 @@ namespace Duplicati.Library.Main.Operation if (m_options.AutoVacuum) { m_result.VacuumResults = new VacuumResults(m_result); - new VacuumHandler(m_options, (VacuumResults)m_result.VacuumResults).Run(); + await new VacuumHandler(m_options, (VacuumResults)m_result.VacuumResults) + .RunAsync(); } m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Backup_Complete); return; diff --git a/Duplicati/Library/Main/Operation/CompactHandler.cs b/Duplicati/Library/Main/Operation/CompactHandler.cs index 5f245b916..2f4b3f02a 100644 --- a/Duplicati/Library/Main/Operation/CompactHandler.cs +++ b/Duplicati/Library/Main/Operation/CompactHandler.cs @@ -24,7 +24,9 @@ using Duplicati.Library.Main.Volumes; using Duplicati.Library.Utility; using System; using System.Collections.Generic; +using System.Data; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -45,76 +47,59 @@ namespace Duplicati.Library.Main.Operation m_result = result; } - public virtual async Task Run(IBackendManager backendManager) + public async Task RunAsync(IBackendManager backendManager) { if (!System.IO.File.Exists(m_options.Dbpath)) throw new Exception(string.Format("Database file does not exist: {0}", m_options.Dbpath)); using (var db = new LocalDeleteDatabase(m_options.Dbpath, "Compact")) + using (var tr = new ReusableTransaction(db)) { - var tr = db.BeginTransaction(); - try + m_result.SetDatabase(db); + Utility.UpdateOptionsFromDb(db, m_options); + Utility.VerifyOptionsAndUpdateDatabase(db, m_options); + + var changed = await DoCompactAsync(db, false, tr, backendManager).ConfigureAwait(false); + + if (changed && m_options.UploadVerificationFile) + await FilelistProcessor.UploadVerificationFile(backendManager, m_options, db, null); + + if (!m_options.Dryrun) { - m_result.SetDatabase(db); - Utility.UpdateOptionsFromDb(db, m_options); - Utility.VerifyOptionsAndUpdateDatabase(db, m_options); - - // TODO: Handle transaction scopes better - var (changed, tr2) = DoCompact(db, false, tr, backendManager).Await(); - tr = tr2; - - if (changed && m_options.UploadVerificationFile) - await FilelistProcessor.UploadVerificationFile(backendManager, m_options, db, null); - - if (!m_options.Dryrun) + tr.Commit("CommitCompact", restart: false); + if (changed) { - using (new Logging.Timer(LOGTAG, "CommitCompact", "CommitCompact")) - tr.Commit(); - if (changed) + db.WriteResults(); + if (m_options.AutoVacuum) { - db.WriteResults(); - if (m_options.AutoVacuum) - { - m_result.VacuumResults = new VacuumResults(m_result); - new VacuumHandler(m_options, (VacuumResults)m_result.VacuumResults).Run(); - } + m_result.VacuumResults = new VacuumResults(m_result); + await new VacuumHandler(m_options, (VacuumResults)m_result.VacuumResults).RunAsync().ConfigureAwait(false); } } - else - tr.Rollback(); - - tr = null; - } - finally - { - if (tr != null) - try { tr.Rollback(); } - catch { } } } } - internal async Task<(bool, System.Data.IDbTransaction)> DoCompact(LocalDeleteDatabase db, bool hasVerifiedBackend, System.Data.IDbTransaction transaction, IBackendManager backendManager) + internal async Task DoCompactAsync(LocalDeleteDatabase db, bool hasVerifiedBackend, ReusableTransaction rtr, IBackendManager backendManager) { - var cancellationToken = CancellationToken.None; - var report = db.GetCompactReport(m_options.VolumeSize, m_options.Threshold, m_options.SmallFileSize, m_options.SmallFileMaxCount, transaction); + var report = db.GetCompactReport(m_options.VolumeSize, m_options.Threshold, m_options.SmallFileSize, m_options.SmallFileMaxCount, rtr.Transaction); report.ReportCompactData(); if (report.ShouldReclaim || report.ShouldCompact) { // Workaround where we allow a running backendmanager to be used if (!hasVerifiedBackend) - await FilelistProcessor.VerifyRemoteList(backendManager, m_options, db, m_result.BackendWriter, true, transaction).ConfigureAwait(false); + await FilelistProcessor.VerifyRemoteList(backendManager, m_options, db, m_result.BackendWriter, true, FilelistProcessor.VerifyMode.VerifyStrict, rtr.Transaction).ConfigureAwait(false); var newvol = new BlockVolumeWriter(m_options); - newvol.VolumeID = db.RegisterRemoteVolume(newvol.RemoteFilename, RemoteVolumeType.Blocks, RemoteVolumeState.Temporary, transaction); + newvol.VolumeID = db.RegisterRemoteVolume(newvol.RemoteFilename, RemoteVolumeType.Blocks, RemoteVolumeState.Temporary, rtr.Transaction); IndexVolumeWriter newvolindex = null; if (m_options.IndexfilePolicy != Options.IndexFileStrategy.None) { newvolindex = new IndexVolumeWriter(m_options); - newvolindex.VolumeID = db.RegisterRemoteVolume(newvolindex.RemoteFilename, RemoteVolumeType.Index, RemoteVolumeState.Temporary, transaction); - db.AddIndexBlockLink(newvolindex.VolumeID, newvol.VolumeID, transaction); + newvolindex.VolumeID = db.RegisterRemoteVolume(newvolindex.RemoteFilename, RemoteVolumeType.Index, RemoteVolumeState.Temporary, rtr.Transaction); + db.AddIndexBlockLink(newvolindex.VolumeID, newvol.VolumeID, rtr.Transaction); } var blocksInVolume = 0L; @@ -137,7 +122,8 @@ namespace Duplicati.Library.Main.Operation .Cast() .ToList(); } - deletedVolumes.AddRange(DoDelete(db, backendManager, fullyDeleteable, ref transaction, cancellationToken)); + await foreach (var d in DoDelete(db, backendManager, fullyDeleteable, rtr, m_result.TaskControl.ProgressToken).ConfigureAwait(false)) + deletedVolumes.Add(d); // This list is used to pick up unused volumes, // so they can be deleted once the upload of the @@ -146,6 +132,10 @@ namespace Duplicati.Library.Main.Operation if (report.ShouldCompact) { + // If we crash now, we may leave partial files + if (!m_options.Dryrun) + db.TerminatedWithActiveUploads = true; + newvolindex?.StartVolume(newvol.RemoteFilename); List volumesToDownload = []; if (report.CompactableVolumes.Any()) @@ -158,17 +148,17 @@ namespace Duplicati.Library.Main.Operation .ToList(); } - using (var q = db.CreateBlockQueryHelper(transaction)) + using (var q = db.CreateBlockQueryHelper(rtr.Transaction)) { - await foreach (var (tmpfile, hash, size, name) in backendManager.GetFilesOverlappedAsync(volumesToDownload, m_result.TaskControl.ProgressToken)) + await foreach (var (tmpfile, hash, size, name) in backendManager.GetFilesOverlappedAsync(volumesToDownload, m_result.TaskControl.ProgressToken).ConfigureAwait(false)) { using (tmpfile) { var entry = new RemoteVolume(name, hash, size); - if (!m_result.TaskControl.ProgressRendevouz().Await()) + if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) { - backendManager.WaitForEmptyAsync(db, transaction, cancellationToken).Await(); - return (false, transaction); + await backendManager.WaitForEmptyAsync(db, rtr.Transaction, m_result.TaskControl.ProgressToken).ConfigureAwait(false); + return false; } downloadedVolumes.Add(new KeyValuePair(entry.Name, entry.Size)); @@ -177,46 +167,43 @@ namespace Duplicati.Library.Main.Operation { foreach (var e in f.Blocks) { - if (q.UseBlock(e.Key, e.Value, transaction)) + if (q.UseBlock(e.Key, e.Value, rtr.Transaction)) { //TODO: How do we get the compression hint? Reverse query for filename in db? var s = f.ReadBlock(e.Key, buffer); if (s != e.Value) throw new Exception(string.Format("Size mismatch problem for block {0}, {1} vs {2}", e.Key, s, e.Value)); - newvol.AddBlock(e.Key, buffer, 0, s, Duplicati.Library.Interface.CompressionHint.Compressible); + newvol.AddBlock(e.Key, buffer, 0, s, Interface.CompressionHint.Compressible); if (newvolindex != null) newvolindex.AddBlock(e.Key, e.Value); - db.RegisterDuplicatedBlock(e.Key, e.Value, newvol.VolumeID, transaction); + db.RegisterDuplicatedBlock(e.Key, e.Value, newvol.VolumeID, rtr.Transaction); blocksInVolume++; - if (newvol.Filesize > m_options.VolumeSize) + if (newvol.Filesize > (m_options.VolumeSize - m_options.Blocksize)) { - FinishVolumeAndUpload(db, backendManager, newvol, newvolindex, uploadedVolumes); + await FinishVolumeAndUpload(db, backendManager, newvol, newvolindex, uploadedVolumes); newvol = new BlockVolumeWriter(m_options); - newvol.VolumeID = db.RegisterRemoteVolume(newvol.RemoteFilename, RemoteVolumeType.Blocks, RemoteVolumeState.Temporary, transaction); + newvol.VolumeID = db.RegisterRemoteVolume(newvol.RemoteFilename, RemoteVolumeType.Blocks, RemoteVolumeState.Temporary, rtr.Transaction); if (m_options.IndexfilePolicy != Options.IndexFileStrategy.None) { newvolindex = new IndexVolumeWriter(m_options); - newvolindex.VolumeID = db.RegisterRemoteVolume(newvolindex.RemoteFilename, RemoteVolumeType.Index, RemoteVolumeState.Temporary, transaction); - db.AddIndexBlockLink(newvolindex.VolumeID, newvol.VolumeID, transaction); + newvolindex.VolumeID = db.RegisterRemoteVolume(newvolindex.RemoteFilename, RemoteVolumeType.Index, RemoteVolumeState.Temporary, rtr.Transaction); + db.AddIndexBlockLink(newvolindex.VolumeID, newvol.VolumeID, rtr.Transaction); newvolindex.StartVolume(newvol.RemoteFilename); } blocksInVolume = 0; // Wait for the backend to catch up - backendManager.WaitForEmptyAsync(db, transaction, cancellationToken).Await(); + await backendManager.WaitForEmptyAsync(db, rtr.Transaction, m_result.TaskControl.ProgressToken).ConfigureAwait(false); // Commit as we have uploaded a volume if (!m_options.Dryrun) - { - transaction.Commit(); - transaction = db.BeginTransaction(); - } + rtr.Commit(); } } } @@ -228,18 +215,22 @@ namespace Duplicati.Library.Main.Operation if (blocksInVolume > 0) { - FinishVolumeAndUpload(db, backendManager, newvol, newvolindex, uploadedVolumes); + await FinishVolumeAndUpload(db, backendManager, newvol, newvolindex, uploadedVolumes).ConfigureAwait(false); } else { - db.RemoveRemoteVolume(newvol.RemoteFilename, transaction); + db.RemoveRemoteVolume(newvol.RemoteFilename, rtr.Transaction); if (newvolindex != null) { - db.RemoveRemoteVolume(newvolindex.RemoteFilename, transaction); + db.RemoveRemoteVolume(newvolindex.RemoteFilename, rtr.Transaction); newvolindex.FinishVolume(null, 0); } } } + + // The remainder of the operation cannot leave partial files + if (!m_options.Dryrun) + db.TerminatedWithActiveUploads = false; } else { @@ -247,7 +238,8 @@ namespace Duplicati.Library.Main.Operation newvol.Dispose(); } - deletedVolumes.AddRange(DoDelete(db, backendManager, deleteableVolumes, ref transaction, cancellationToken)); + await foreach (var d in DoDelete(db, backendManager, deleteableVolumes, rtr, m_result.TaskControl.ProgressToken).ConfigureAwait(false)) + deletedVolumes.Add(d); var downloadSize = downloadedVolumes.Where(x => x.Value >= 0).Aggregate(0L, (a, x) => a + x.Value); var deletedSize = deletedVolumes.Where(x => x.Value >= 0).Aggregate(0L, (a, x) => a + x.Value); @@ -291,25 +283,25 @@ namespace Duplicati.Library.Main.Operation Library.Utility.Utility.FormatSizeString(m_result.DeletedFileSize - m_result.UploadedFileSize)); } - backendManager.WaitForEmptyAsync(db, transaction, cancellationToken).Await(); + await backendManager.WaitForEmptyAsync(db, rtr.Transaction, m_result.TaskControl.ProgressToken).ConfigureAwait(false); m_result.EndTime = DateTime.UtcNow; - return ((m_result.DeletedFileCount + m_result.UploadedFileCount) > 0, transaction); + return (m_result.DeletedFileCount + m_result.UploadedFileCount) > 0; } else { m_result.EndTime = DateTime.UtcNow; - return (false, transaction); + return false; } } - private IEnumerable> DoDelete(LocalDeleteDatabase db, IBackendManager backend, IEnumerable deleteableVolumes, ref System.Data.IDbTransaction transaction, CancellationToken cancellationToken) + private async IAsyncEnumerable> DoDelete(LocalDeleteDatabase db, IBackendManager backend, IEnumerable deleteableVolumes, ReusableTransaction rtr, [EnumeratorCancellation] CancellationToken cancellationToken) { // Find volumes that can be deleted - var remoteFilesToRemove = db.ReOrderDeleteableVolumes(deleteableVolumes, transaction).ToList(); + var remoteFilesToRemove = db.ReOrderDeleteableVolumes(deleteableVolumes, rtr.Transaction).ToList(); // Make sure we do not re-assign blocks to any of the volumes we are about to delete - var toRemoveVolumeIds = db.GetRemoteVolumeIDs(remoteFilesToRemove.Select(x => x.Name), transaction) + var toRemoveVolumeIds = db.GetRemoteVolumeIDs(remoteFilesToRemove.Select(x => x.Name), rtr.Transaction) .Select(x => x.Value) .Distinct() .ToList(); @@ -317,23 +309,21 @@ namespace Duplicati.Library.Main.Operation // Mark all volumes and relevant index files as disposable foreach (var f in remoteFilesToRemove) { - db.PrepareForDelete(f.Name, toRemoveVolumeIds, transaction); - db.UpdateRemoteVolume(f.Name, RemoteVolumeState.Deleting, f.Size, f.Hash, transaction); + db.PrepareForDelete(f.Name, toRemoveVolumeIds, rtr.Transaction); + db.UpdateRemoteVolume(f.Name, RemoteVolumeState.Deleting, f.Size, f.Hash, rtr.Transaction); } // Before we commit the current state, make sure the backend has caught up - backend.WaitForEmptyAsync(db, transaction, cancellationToken).Await(); + await backend.WaitForEmptyAsync(db, rtr.Transaction, cancellationToken).ConfigureAwait(false); if (!m_options.Dryrun) - { - transaction.Commit(); - transaction = db.BeginTransaction(); - } + rtr.Commit(); - return PerformDelete(backend, remoteFilesToRemove, cancellationToken); + await foreach (var d in PerformDelete(backend, remoteFilesToRemove, cancellationToken).ConfigureAwait(false)) + yield return d; } - private void FinishVolumeAndUpload(LocalDeleteDatabase db, IBackendManager backendManager, BlockVolumeWriter newvol, IndexVolumeWriter newvolindex, List> uploadedVolumes) + private async Task FinishVolumeAndUpload(LocalDeleteDatabase db, IBackendManager backendManager, BlockVolumeWriter newvol, IndexVolumeWriter newvolindex, List> uploadedVolumes) { Action indexVolumeFinished = () => { @@ -350,17 +340,17 @@ namespace Duplicati.Library.Main.Operation if (newvolindex != null) uploadedVolumes.Add(new KeyValuePair(newvolindex.RemoteFilename, newvolindex.Filesize)); if (!m_options.Dryrun) - backendManager.PutAsync(newvol, newvolindex, indexVolumeFinished, false, CancellationToken.None).Await(); + await backendManager.PutAsync(newvol, newvolindex, indexVolumeFinished, false, m_result.TaskControl.ProgressToken).ConfigureAwait(false); else Logging.Log.WriteDryrunMessage(LOGTAG, "WouldUploadGeneratedBlockset", "Would upload generated blockset of size {0}", Library.Utility.Utility.FormatSizeString(newvol.Filesize)); } - private IEnumerable> PerformDelete(IBackendManager backendManager, IEnumerable list, CancellationToken cancelToken) + private async IAsyncEnumerable> PerformDelete(IBackendManager backendManager, IEnumerable list, [EnumeratorCancellation] CancellationToken cancelToken) { foreach (var f in list) { if (!m_options.Dryrun) - backendManager.DeleteAsync(f.Name, f.Size, false, cancelToken).Await(); + await backendManager.DeleteAsync(f.Name, f.Size, false, cancelToken).ConfigureAwait(false); else Logging.Log.WriteDryrunMessage(LOGTAG, "WouldDeleteRemoteFile", "Would delete remote file: {0}, size: {1}", f.Name, Library.Utility.Utility.FormatSizeString(f.Size)); diff --git a/Duplicati/Library/Main/Operation/CreateBugReportHandler.cs b/Duplicati/Library/Main/Operation/CreateBugReportHandler.cs index cc0896d4f..740f76734 100644 --- a/Duplicati/Library/Main/Operation/CreateBugReportHandler.cs +++ b/Duplicati/Library/Main/Operation/CreateBugReportHandler.cs @@ -1,28 +1,30 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using System.IO; +using System.Threading.Tasks; using Duplicati.Library.Interface; using Duplicati.Library.Main.Database; +using Duplicati.Library.Utility; namespace Duplicati.Library.Main.Operation { @@ -43,18 +45,18 @@ namespace Duplicati.Library.Main.Operation m_result = result; } - public void Run() + public async Task RunAsync() { - var ext = System.IO.Path.GetExtension(m_targetpath); + var ext = Path.GetExtension(m_targetpath); var module = m_options.CompressionModule; if (ext == "" || string.Compare(ext, 1, module, 0, module.Length, StringComparison.OrdinalIgnoreCase) != 0) m_targetpath = m_targetpath + "." + module; - if (System.IO.File.Exists(m_targetpath)) + if (File.Exists(m_targetpath)) throw new UserInformationException(string.Format("Output file already exists, not overwriting: {0}", m_targetpath), "BugReportTargetAlreadyExists"); - if (!System.IO.File.Exists(m_options.Dbpath)) + if (!File.Exists(m_options.Dbpath)) throw new UserInformationException(string.Format("Database file does not exist: {0}", m_options.Dbpath), "BugReportSourceDatabaseNotFound"); m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.BugReport_Running); @@ -62,27 +64,25 @@ namespace Duplicati.Library.Main.Operation Logging.Log.WriteInformationMessage(LOGTAG, "ScrubbingFilenames", "Scrubbing filenames from database, this may take a while, please wait"); - using (var tmp = new Library.Utility.TempFile()) + using (var tmp = new TempFile()) { - System.IO.File.Copy(m_options.Dbpath, tmp, true); + File.Copy(m_options.Dbpath, tmp, true); using (var db = new LocalBugReportDatabase(tmp)) { m_result.SetDatabase(db); db.Fix(); if (m_options.AutoVacuum) - { db.Vacuum(); - } } - using (var stream = new System.IO.FileStream(m_targetpath, FileMode.Create, FileAccess.Write, FileShare.Read)) + using (var stream = new FileStream(m_targetpath, FileMode.Create, FileAccess.Write, FileShare.Read)) using (ICompression cm = DynamicLoader.CompressionLoader.GetModule(module, stream, Interface.ArchiveMode.Write, m_options.RawOptions)) { - using (var cs = cm.CreateFile("log-database.sqlite", Duplicati.Library.Interface.CompressionHint.Compressible, DateTime.UtcNow)) - using (var fs = System.IO.File.Open(tmp, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.ReadWrite)) - Library.Utility.Utility.CopyStream(fs, cs); + using (var cs = cm.CreateFile("log-database.sqlite", CompressionHint.Compressible, DateTime.UtcNow)) + using (var fs = File.Open(tmp, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + await Library.Utility.Utility.CopyStreamAsync(fs, cs, m_result.TaskControl.ProgressToken).ConfigureAwait(false); - using (var cs = new System.IO.StreamWriter(cm.CreateFile("system-info.txt", Duplicati.Library.Interface.CompressionHint.Compressible, DateTime.UtcNow))) + using (var cs = new StreamWriter(cm.CreateFile("system-info.txt", CompressionHint.Compressible, DateTime.UtcNow))) foreach (var line in SystemInfoHandler.GetSystemInfo()) cs.WriteLine(line); } diff --git a/Duplicati/Library/Main/Operation/DeleteHandler.cs b/Duplicati/Library/Main/Operation/DeleteHandler.cs index fdd233154..1d89e43ed 100644 --- a/Duplicati/Library/Main/Operation/DeleteHandler.cs +++ b/Duplicati/Library/Main/Operation/DeleteHandler.cs @@ -26,6 +26,7 @@ using Duplicati.Library.Interface; using Duplicati.Library.Main.Database; using Duplicati.Library.Utility; using System.Threading; +using System.Threading.Tasks; namespace Duplicati.Library.Main.Operation { @@ -45,59 +46,43 @@ namespace Duplicati.Library.Main.Operation m_result = result; } - public void Run(IBackendManager backendManager) + public async Task RunAsync(IBackendManager backendManager) { if (!System.IO.File.Exists(m_options.Dbpath)) throw new UserInformationException(string.Format("Database file does not exist: {0}", m_options.Dbpath), "DatabaseFileMissing"); - using (var db = new Database.LocalDeleteDatabase(m_options.Dbpath, "Delete")) + using (var db = new LocalDeleteDatabase(m_options.Dbpath, "Delete")) + using (var tr = new ReusableTransaction(db)) { - var tr = db.BeginTransaction(); - try + m_result.SetDatabase(db); + Utility.UpdateOptionsFromDb(db, m_options); + Utility.VerifyOptionsAndUpdateDatabase(db, m_options); + + await DoRunAsync(db, tr, false, false, backendManager).ConfigureAwait(false); + + if (!m_options.Dryrun) { - m_result.SetDatabase(db); - Utility.UpdateOptionsFromDb(db, m_options); - Utility.VerifyOptionsAndUpdateDatabase(db, m_options); - - DoRun(db, ref tr, false, false, backendManager); - - if (!m_options.Dryrun) - { - using (new Logging.Timer(LOGTAG, "CommitDelete", "CommitDelete")) - tr.Commit(); - - db.WriteResults(); - } - else - tr.Rollback(); - - tr = null; - } - finally - { - if (tr != null) - try { tr.Rollback(); } - catch { } + tr.Commit("ComitDelete", restart: false); + db.WriteResults(); } } } - public void DoRun(Database.LocalDeleteDatabase db, ref System.Data.IDbTransaction transaction, bool hasVerifiedBackend, bool forceCompact, IBackendManager backendManager) + public async Task DoRunAsync(LocalDeleteDatabase db, ReusableTransaction rtr, bool hasVerifiedBackend, bool forceCompact, IBackendManager backendManager) { - CancellationToken cancellationToken = CancellationToken.None; if (!hasVerifiedBackend) - FilelistProcessor.VerifyRemoteList(backendManager, m_options, db, m_result.BackendWriter, true, transaction).Await(); + await FilelistProcessor.VerifyRemoteList(backendManager, m_options, db, m_result.BackendWriter, latestVolumesOnly: true, verifyMode: FilelistProcessor.VerifyMode.VerifyStrict, rtr.Transaction).ConfigureAwait(false); IListResultFileset[] filesets = db.FilesetsWithBackupVersion.ToArray(); List versionsToDelete = [ - .. new SpecificVersionsRemover(this.m_options).GetFilesetsToDelete(filesets), - .. new KeepTimeRemover(this.m_options).GetFilesetsToDelete(filesets), - .. new RetentionPolicyRemover(this.m_options).GetFilesetsToDelete(filesets), + .. new SpecificVersionsRemover(m_options).GetFilesetsToDelete(filesets), + .. new KeepTimeRemover(m_options).GetFilesetsToDelete(filesets), + .. new RetentionPolicyRemover(m_options).GetFilesetsToDelete(filesets), ]; // When determining the number of full versions to keep, we need to ignore the versions already marked for removal. - versionsToDelete.AddRange(new KeepVersionsRemover(this.m_options).GetFilesetsToDelete(filesets.Except(versionsToDelete))); + versionsToDelete.AddRange(new KeepVersionsRemover(m_options).GetFilesetsToDelete(filesets.Except(versionsToDelete))); if (!m_options.AllowFullRemoval && filesets.Length == versionsToDelete.Count) { @@ -108,31 +93,28 @@ namespace Duplicati.Library.Main.Operation if (versionsToDelete.Count > 0) Logging.Log.WriteInformationMessage(LOGTAG, "DeleteRemoteFileset", "Deleting {0} remote fileset(s) ...", versionsToDelete.Count); - var lst = db.DropFilesetsFromTable(versionsToDelete.Select(x => x.Time).ToArray(), transaction).ToArray(); + var lst = db.DropFilesetsFromTable(versionsToDelete.Select(x => x.Time).ToArray(), rtr.Transaction).ToArray(); foreach (var f in lst) - db.UpdateRemoteVolume(f.Key, RemoteVolumeState.Deleting, f.Value, null, transaction); + db.UpdateRemoteVolume(f.Key, RemoteVolumeState.Deleting, f.Value, null, rtr.Transaction); if (!m_options.Dryrun) - { - transaction.Commit(); - transaction = db.BeginTransaction(); - } + rtr.Commit(); foreach (var f in lst) { - if (!m_result.TaskControl.ProgressRendevouz().Await()) + if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) { - backendManager.WaitForEmptyAsync(db, transaction, cancellationToken).Await(); + await backendManager.WaitForEmptyAsync(db, rtr.Transaction, m_result.TaskControl.ProgressToken).ConfigureAwait(false); return; } if (!m_options.Dryrun) - backendManager.DeleteAsync(f.Key, f.Value, false, cancellationToken).Await(); + await backendManager.DeleteAsync(f.Key, f.Value, false, m_result.TaskControl.ProgressToken).ConfigureAwait(false); else Logging.Log.WriteDryrunMessage(LOGTAG, "WouldDeleteRemoteFileset", "Would delete remote fileset: {0}", f.Key); } - backendManager.WaitForEmptyAsync(db, transaction, cancellationToken).Await(); + await backendManager.WaitForEmptyAsync(db, rtr.Transaction, m_result.TaskControl.ProgressToken).ConfigureAwait(false); var count = lst.Length; if (!m_options.Dryrun) @@ -157,8 +139,8 @@ namespace Duplicati.Library.Main.Operation if (!m_options.NoAutoCompact && (forceCompact || versionsToDelete.Count > 0)) { m_result.CompactResults = new CompactResults(m_result); - var (_, tr) = new CompactHandler(m_options, (CompactResults)m_result.CompactResults).DoCompact(db, true, transaction, backendManager).Await(); - transaction = tr; + await new CompactHandler(m_options, (CompactResults)m_result.CompactResults) + .DoCompactAsync(db, true, rtr, backendManager).ConfigureAwait(false); } m_result.SetResults(versionsToDelete.Select(v => new Tuple(v.Version, v.Time)), m_options.Dryrun); diff --git a/Duplicati/Library/Main/Operation/FilelistProcessor.cs b/Duplicati/Library/Main/Operation/FilelistProcessor.cs index 1c2f69290..a1f1a99e5 100644 --- a/Duplicati/Library/Main/Operation/FilelistProcessor.cs +++ b/Duplicati/Library/Main/Operation/FilelistProcessor.cs @@ -26,13 +26,36 @@ using System.Data; using System.Linq; using Duplicati.Library.Interface; using System.Threading; -using Duplicati.Library.Utility; using System.Threading.Tasks; namespace Duplicati.Library.Main.Operation { internal static class FilelistProcessor { + /// + /// The approach to take when verifying the filelist + /// + public enum VerifyMode + { + /// + /// Just verify the list, do not repair partials + /// + VerifyOnly, + /// + /// Verify the list and fail if there are partials + /// + VerifyStrict, + /// + /// Verify the list and repair partials + /// + VerifyAndClean, + /// + /// Force a verification and repair, even if the last operation was successful + /// + VerifyAndCleanForced + } + + /// /// The tag used for logging /// @@ -79,13 +102,25 @@ namespace Duplicati.Library.Main.Operation } } - public static async Task VerifyRemoteList(IBackendManager backend, Options options, LocalDatabase database, IBackendWriter backendWriter, bool latestVolumesOnly, IDbTransaction transaction) + /// + /// Helper method that verifies uploaded volumes and updates their state in the database. + /// Throws an error if there are issues with the remote storage + /// + /// The backend instance to use + /// The options used + /// The database to compare with + /// The backend writer to use + /// True if only the latest volumes should be verified + /// The mode to use for verification + /// An optional transaction object + /// An awaitable task + public static async Task VerifyRemoteList(IBackendManager backend, Options options, LocalDatabase database, IBackendWriter backendWriter, bool latestVolumesOnly, VerifyMode verifyMode, IDbTransaction transaction) { if (!options.NoBackendverification) { LocalBackupDatabase backupDatabase = new LocalBackupDatabase(database, options); IEnumerable protectedFiles = backupDatabase.GetTemporaryFilelistVolumeNames(latestVolumesOnly, transaction); - await VerifyRemoteList(backend, options, database, backendWriter, protectedFiles).ConfigureAwait(false); + await VerifyRemoteList(backend, options, database, backendWriter, protectedFiles, [], logErrors: true, verifyMode: verifyMode).ConfigureAwait(false); } } @@ -98,10 +133,13 @@ namespace Duplicati.Library.Main.Operation /// The database to compare with /// The log instance to use /// Filenames that should be exempted from deletion + /// Filenames that should be exempted from strict verification + /// The mode to use for verification /// Disable the logging of errors to prevent spamming the log; exceptions will be thrown regardless - public static async Task VerifyRemoteList(IBackendManager backend, Options options, LocalDatabase database, IBackendWriter log, IEnumerable protectedFiles = null, bool logErrors = true) + /// An awaitable task + public static async Task VerifyRemoteList(IBackendManager backend, Options options, LocalDatabase database, IBackendWriter log, IEnumerable protectedFiles, IEnumerable strictExcemptFiles, bool logErrors, VerifyMode verifyMode) { - var tp = await RemoteListAnalysis(backend, options, database, log, protectedFiles).ConfigureAwait(false); + var tp = await RemoteListAnalysis(backend, options, database, log, protectedFiles, strictExcemptFiles, verifyMode).ConfigureAwait(false); long extraCount = 0; long missingCount = 0; @@ -208,11 +246,23 @@ namespace Duplicati.Library.Main.Operation /// The options used /// The database to compare with /// Filenames that should be exempted from deletion - public static async Task RemoteListAnalysis(IBackendManager backendManager, Options options, LocalDatabase database, IBackendWriter log, IEnumerable protectedFiles) + /// Filenames that should be exempted from strict verification + /// The mode to use for verification + /// The result of the analysis + public static async Task RemoteListAnalysis(IBackendManager backendManager, Options options, LocalDatabase database, IBackendWriter log, IEnumerable protectedFiles, IEnumerable strictExcemptFiles, VerifyMode verifyMode) { + // If the last operation completed, no cleanup should be required + if (verifyMode == VerifyMode.VerifyAndClean && !database.TerminatedWithActiveUploads) + verifyMode = VerifyMode.VerifyStrict; + + // Force cleanup should set the mode to cleanup + if (verifyMode == VerifyMode.VerifyAndCleanForced) + verifyMode = VerifyMode.VerifyAndClean; + var rawlist = await backendManager.ListAsync(CancellationToken.None).ConfigureAwait(false); var lookup = new Dictionary(); - protectedFiles = protectedFiles ?? Enumerable.Empty(); + protectedFiles = protectedFiles ?? []; + strictExcemptFiles = strictExcemptFiles ?? []; var remotelist = (from n in rawlist let p = Volumes.VolumeBase.ParseFilename(n) @@ -235,6 +285,7 @@ namespace Duplicati.Library.Main.Operation select n).ToList(); log.KnownFileCount = remotelist.Count; + log.KnownFilesets = filesets.Count; long knownFileSize = remotelist.Select(x => Math.Max(0, x.File.Size)).Sum(); log.KnownFileSize = knownFileSize; log.UnknownFileCount = unknownlist.Count; @@ -249,7 +300,8 @@ namespace Duplicati.Library.Main.Operation var missing = new List(); var missingHash = new List>(); - var cleanupRemovedRemoteVolumes = new HashSet(); + var temporaryAndDeletedVolumes = new HashSet(); + var missingUploadingVolumes = new HashSet(); foreach (var e in database.DuplicateRemoteVolumes()) { @@ -278,7 +330,7 @@ namespace Duplicati.Library.Main.Operation { case RemoteVolumeState.Deleted: if (remoteFound) - Logging.Log.WriteInformationMessage(LOGTAG, "IgnoreRemoteDeletedFile", "ignoring remote file listed as {0}: {1}", i.State, i.Name); + Logging.Log.WriteInformationMessage(LOGTAG, "IgnoreRemoteDeletedFile", "Ignoring remote file listed as {0}: {1}", i.State, i.Name); break; @@ -286,25 +338,36 @@ namespace Duplicati.Library.Main.Operation case RemoteVolumeState.Deleting: if (remoteFound) { - Logging.Log.WriteInformationMessage(LOGTAG, "RemoveUnwantedRemoteFile", "removing remote file listed as {0}: {1}", i.State, i.Name); - await backendManager.DeleteAsync(i.Name, i.Size, true, CancellationToken.None).ConfigureAwait(false); + if (options.Dryrun) + { + Logging.Log.WriteDryrunMessage(LOGTAG, "WouldDeleteRemoteFile", "Would delete remote file: {0}", i.Name); + } + else if (verifyMode == VerifyMode.VerifyOnly) + { + Logging.Log.WriteInformationMessage(LOGTAG, "WouldRemoveUnwantedRemoteFile", "Would remove remote file listed as {0}: {1}, but defering as {2} is requested", i.State, i.Name, verifyMode); + } + else + { + Logging.Log.WriteInformationMessage(LOGTAG, "RemoveUnwantedRemoteFile", "Removing remote file listed as {0}: {1}", i.State, i.Name); + await backendManager.DeleteAsync(i.Name, i.Size, true, CancellationToken.None).ConfigureAwait(false); + } } else { if (i.DeleteGracePeriod > DateTime.UtcNow) { - Logging.Log.WriteInformationMessage(LOGTAG, "KeepDeleteRequest", "keeping delete request for {0} until {1}", i.Name, i.DeleteGracePeriod.ToLocalTime()); + Logging.Log.WriteInformationMessage(LOGTAG, "KeepDeleteRequest", "Keeping delete request for {0} until {1}", i.Name, i.DeleteGracePeriod.ToLocalTime()); } else { if (i.State == RemoteVolumeState.Temporary && protectedFiles.Any(pf => pf == i.Name)) { - Logging.Log.WriteInformationMessage(LOGTAG, "KeepIncompleteFile", "keeping protected incomplete remote file listed as {0}: {1}", i.State, i.Name); + Logging.Log.WriteInformationMessage(LOGTAG, "KeepIncompleteFile", "Keeping protected incomplete remote file listed as {0}: {1}", i.State, i.Name); } else { - Logging.Log.WriteInformationMessage(LOGTAG, "RemoteUnwantedMissingFile", "removing file listed as {0}: {1}", i.State, i.Name); - cleanupRemovedRemoteVolumes.Add(i.Name); + Logging.Log.WriteInformationMessage(LOGTAG, "RemoteUnwantedMissingFile", "Removing file listed as {0}: {1}", i.State, i.Name); + temporaryAndDeletedVolumes.Add(i.Name); } } } @@ -312,20 +375,25 @@ namespace Duplicati.Library.Main.Operation case RemoteVolumeState.Uploading: if (remoteFound && correctSize && r.File.Size >= 0) { - Logging.Log.WriteInformationMessage(LOGTAG, "PromotingCompleteFile", "promoting uploaded complete file from {0} to {2}: {1}", i.State, i.Name, RemoteVolumeState.Uploaded); + Logging.Log.WriteInformationMessage(LOGTAG, "PromotingCompleteFile", "Promoting uploaded complete file from {0} to {2}: {1}", i.State, i.Name, RemoteVolumeState.Uploaded); database.UpdateRemoteVolume(i.Name, RemoteVolumeState.Uploaded, i.Size, i.Hash); } else if (!remoteFound) { if (protectedFiles.Any(pf => pf == i.Name)) { - Logging.Log.WriteInformationMessage(LOGTAG, "KeepIncompleteFile", "keeping protected incomplete remote file listed as {0}: {1}", i.State, i.Name); + Logging.Log.WriteInformationMessage(LOGTAG, "KeepIncompleteFile", "Keeping protected incomplete remote file listed as {0}: {1}", i.State, i.Name); database.UpdateRemoteVolume(i.Name, RemoteVolumeState.Temporary, i.Size, i.Hash, false, new TimeSpan(0), null, null); } + else if (verifyMode == VerifyMode.VerifyStrict && !strictExcemptFiles.Any(pf => pf == i.Name)) + { + Logging.Log.WriteErrorMessage(LOGTAG, "UnexpectedUploadingFile", null, "Unexpected file in uploading state: {0}", i.Name); + throw new RemoteListVerificationException($"The missing remote volume {i.Name} is in uploading state and strict mode is on", "UnexpectedUploadingFile"); + } else { - Logging.Log.WriteInformationMessage(LOGTAG, "SchedulingMissingFileForDelete", "scheduling missing file for deletion, currently listed as {0}: {1}", i.State, i.Name); - cleanupRemovedRemoteVolumes.Add(i.Name); + Logging.Log.WriteInformationMessage(LOGTAG, "SchedulingMissingFileForDelete", "Scheduling missing file for deletion, currently listed as {0}: {1}", i.State, i.Name); + missingUploadingVolumes.Add(i.Name); database.UpdateRemoteVolume(i.Name, RemoteVolumeState.Deleting, i.Size, i.Hash, false, TimeSpan.FromHours(2), null, null); } } @@ -333,12 +401,28 @@ namespace Duplicati.Library.Main.Operation { if (protectedFiles.Any(pf => pf == i.Name)) { - Logging.Log.WriteInformationMessage(LOGTAG, "KeepIncompleteFile", "keeping protected incomplete remote file listed as {0}: {1}", i.State, i.Name); + Logging.Log.WriteInformationMessage(LOGTAG, "KeepIncompleteFile", "Keeping protected incomplete remote file listed as {0}: {1}", i.State, i.Name); + } + else if (verifyMode == VerifyMode.VerifyStrict && !strictExcemptFiles.Any(pf => pf == i.Name)) + { + Logging.Log.WriteErrorMessage(LOGTAG, "UnexpectedUploadingFile", null, "Unexpected file in uploading state: {0}", i.Name); + throw new RemoteListVerificationException($"The remote volume {i.Name} is in uploading state, partially uploaded, and strict mode is on", "UnexpectedUploadingFile"); } else { - Logging.Log.WriteInformationMessage(LOGTAG, "Remove incomplete file", "removing incomplete remote file listed as {0}: {1}", i.State, i.Name); - await backendManager.DeleteAsync(i.Name, i.Size, true, CancellationToken.None).ConfigureAwait(false); + if (options.Dryrun) + { + Logging.Log.WriteDryrunMessage(LOGTAG, "WouldDeleteRemoteFile", "Would delete remote file: {0}", i.Name); + } + else if (verifyMode == VerifyMode.VerifyOnly) + { + Logging.Log.WriteInformationMessage(LOGTAG, "WouldDeleteIncompleteFile", "Would remove incomplete remote file listed as {0}: {1}, but defering as {2} is requested", i.State, i.Name, verifyMode); + } + else + { + Logging.Log.WriteInformationMessage(LOGTAG, "RemovingIncompleteFile", "Removing incomplete remote file listed as {0}: {1}", i.State, i.Name); + await backendManager.DeleteAsync(i.Name, i.Size, true, CancellationToken.None).ConfigureAwait(false); + } } } break; @@ -362,18 +446,30 @@ namespace Duplicati.Library.Main.Operation break; default: - Logging.Log.WriteWarningMessage(LOGTAG, "UnknownFileState", null, "unknown state for remote file listed as {0}: {1}", i.State, i.Name); + Logging.Log.WriteWarningMessage(LOGTAG, "UnknownFileState", null, "Unknown state for remote file listed as {0}: {1}", i.State, i.Name); break; } - - await backendManager.WaitForEmptyAsync(database, null, CancellationToken.None).ConfigureAwait(false); } - // cleanup deleted volumes in DB en block - database.RemoveRemoteVolumes(cleanupRemovedRemoteVolumes, null); + await backendManager.WaitForEmptyAsync(database, null, CancellationToken.None).ConfigureAwait(false); + + // Batch cleanup deleted volumes in DB + if (verifyMode == VerifyMode.VerifyStrict && missingUploadingVolumes.Count > 0) + throw new RemoteListVerificationException($"The remote volumes {string.Join(", ", missingUploadingVolumes)} are supposed to be deleted, but strict mode is on. Try running the \"repair\" command", "DeleteDuringStrictMode"); + else if (verifyMode == VerifyMode.VerifyAndClean || verifyMode == VerifyMode.VerifyStrict) + { + database.RemoveRemoteVolumes(missingUploadingVolumes.Concat(temporaryAndDeletedVolumes), null); + // Clear the flag after we have cleaned up + if (!options.Dryrun) + database.TerminatedWithActiveUploads = false; + } + else if (verifyMode == VerifyMode.VerifyOnly && database.TerminatedWithActiveUploads) + { + Logging.Log.WriteWarningMessage(LOGTAG, "ActiveUploadsDetected", null, "Active uploads detected, but no cleanup was performed. Run the \"repair\" command to clean up the incomplete files"); + } foreach (var i in missingHash) - Logging.Log.WriteWarningMessage(LOGTAG, "MissingRemoteHash", null, "remote file {1} is listed as {0} with size {2} but should be {3}, please verify the sha256 hash \"{4}\"", i.Item2.State, i.Item2.Name, i.Item1, i.Item2.Size, i.Item2.Hash); + Logging.Log.WriteWarningMessage(LOGTAG, "MissingRemoteHash", null, "Remote file {1} is listed as {0} with size {2} but should be {3}, please verify the sha256 hash \"{4}\"", i.Item2.State, i.Item2.Name, i.Item1, i.Item2.Size, i.Item2.Hash); return new RemoteAnalysisResult() { diff --git a/Duplicati/Library/Main/Operation/ListAffected.cs b/Duplicati/Library/Main/Operation/ListAffected.cs index 1608fc5b1..50c1d3694 100644 --- a/Duplicati/Library/Main/Operation/ListAffected.cs +++ b/Duplicati/Library/Main/Operation/ListAffected.cs @@ -1,28 +1,30 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using System.Linq; using System.Collections.Generic; using Duplicati.Library.Interface; +using System.Threading.Tasks; +using System.IO; namespace Duplicati.Library.Main.Operation { @@ -36,13 +38,13 @@ namespace Duplicati.Library.Main.Operation m_options = options; m_result = result; } - - public void Run(List args, Action callback = null) + + public Task RunAsync(List args, Action callback = null) { - if (!System.IO.File.Exists(m_options.Dbpath)) + if (!File.Exists(m_options.Dbpath)) throw new UserInformationException(string.Format("Database file does not exist: {0}", m_options.Dbpath), "DatabaseDoesNotExist"); - using(var db = new Database.LocalListAffectedDatabase(m_options.Dbpath)) + using (var db = new Database.LocalListAffectedDatabase(m_options.Dbpath)) { m_result.SetDatabase(db); if (callback == null) @@ -66,6 +68,8 @@ namespace Duplicati.Library.Main.Operation callback(m_result); } } + + return Task.CompletedTask; } } } diff --git a/Duplicati/Library/Main/Operation/ListBrokenFilesHandler.cs b/Duplicati/Library/Main/Operation/ListBrokenFilesHandler.cs index 4212d1bb4..7e82c7754 100644 --- a/Duplicati/Library/Main/Operation/ListBrokenFilesHandler.cs +++ b/Duplicati/Library/Main/Operation/ListBrokenFilesHandler.cs @@ -22,7 +22,9 @@ using System; using System.Collections; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Threading.Tasks; using Duplicati.Library.Interface; using Duplicati.Library.Utility; @@ -43,19 +45,19 @@ namespace Duplicati.Library.Main.Operation m_result = result; } - public void Run(IBackendManager backendManager, IFilter filter, Func callbackhandler = null) + public async Task RunAsync(IBackendManager backendManager, IFilter filter, Func callbackhandler = null) { - if (!System.IO.File.Exists(m_options.Dbpath)) + if (!File.Exists(m_options.Dbpath)) throw new UserInformationException(string.Format("Database file does not exist: {0}", m_options.Dbpath), "DatabaseDoesNotExist"); using (var db = new Database.LocalListBrokenFilesDatabase(m_options.Dbpath)) using (var tr = db.BeginTransaction()) - DoRun(backendManager, db, tr, filter, callbackhandler); + await DoRunAsync(backendManager, db, tr, filter, callbackhandler).ConfigureAwait(false); } - public static Tuple[] GetBrokenFilesetsFromRemote(IBackendManager backendManager, BasicResults result, Database.LocalListBrokenFilesDatabase db, System.Data.IDbTransaction transaction, Options options, out List missing) + public static async Task<(Tuple[], List Missing)> GetBrokenFilesetsFromRemote(IBackendManager backendManager, BasicResults result, Database.LocalListBrokenFilesDatabase db, System.Data.IDbTransaction transaction, Options options) { - missing = null; + List missing = null; var brokensets = db.GetBrokenFilesets(options.Time, options.Version, transaction).ToArray(); if (brokensets.Length == 0) @@ -65,7 +67,7 @@ namespace Duplicati.Library.Main.Operation Logging.Log.WriteInformationMessage(LOGTAG, "NoBrokenFilesetsInDatabase", "No broken filesets found in database, checking for missing remote files"); - var remotestate = FilelistProcessor.RemoteListAnalysis(backendManager, options, db, result.BackendWriter, null).Await(); + var remotestate = await FilelistProcessor.RemoteListAnalysis(backendManager, options, db, result.BackendWriter, null, null, FilelistProcessor.VerifyMode.VerifyOnly).ConfigureAwait(false); if (!remotestate.ParsedVolumes.Any()) throw new UserInformationException("No remote volumes were found, refusing purge", "CannotPurgeWithNoRemoteVolumes"); @@ -73,7 +75,7 @@ namespace Duplicati.Library.Main.Operation if (missing.Count == 0) { Logging.Log.WriteInformationMessage(LOGTAG, "NoMissingFilesFound", "Skipping operation because no files were found to be missing, and no filesets were recorded as broken."); - return null; + return (null, missing); } // Mark all volumes as disposable @@ -87,10 +89,10 @@ namespace Duplicati.Library.Main.Operation brokensets = db.GetBrokenFilesets(options.Time, options.Version, transaction).ToArray(); } - return brokensets; + return (brokensets, missing); } - private void DoRun(IBackendManager backendManager, Database.LocalListBrokenFilesDatabase db, System.Data.IDbTransaction transaction, Library.Utility.IFilter filter, Func callbackhandler) + private async Task DoRunAsync(IBackendManager backendManager, Database.LocalListBrokenFilesDatabase db, System.Data.IDbTransaction transaction, IFilter filter, Func callbackhandler) { if (filter != null && !filter.Empty) throw new UserInformationException("Filters are not supported for this operation", "FiltersAreNotSupportedForListBrokenFiles"); @@ -98,14 +100,13 @@ namespace Duplicati.Library.Main.Operation if (db.PartiallyRecreated) throw new UserInformationException("The command does not work on partially recreated databases", "ListBrokenFilesDoesNotWorkOnPartialDatabase"); - List missing; - var brokensets = GetBrokenFilesetsFromRemote(backendManager, m_result, db, transaction, m_options, out missing); + (var brokensets, var missing) = await GetBrokenFilesetsFromRemote(backendManager, m_result, db, transaction, m_options).ConfigureAwait(false); if (brokensets == null) return; if (brokensets.Length == 0) { - m_result.BrokenFiles = new Tuple>>[0]; + m_result.BrokenFiles = []; if (missing == null) Logging.Log.WriteInformationMessage(LOGTAG, "NoBrokenFilesets", "Found no broken filesets"); diff --git a/Duplicati/Library/Main/Operation/ListChangesHandler.cs b/Duplicati/Library/Main/Operation/ListChangesHandler.cs index 037532e38..44ac5bca8 100644 --- a/Duplicati/Library/Main/Operation/ListChangesHandler.cs +++ b/Duplicati/Library/Main/Operation/ListChangesHandler.cs @@ -23,6 +23,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; +using System.Threading.Tasks; using Duplicati.Library.Interface; using Duplicati.Library.Utility; @@ -50,7 +51,7 @@ namespace Duplicati.Library.Main.Operation Tuple res; if (!long.TryParse(value, out indexValue)) { - var t = Library.Utility.Timeparser.ParseTimeInterval(value, DateTime.Now, true); + var t = Timeparser.ParseTimeInterval(value, DateTime.Now, true); res = list.OrderBy(x => Math.Abs((x.Item2 - t).Ticks)).First(); } else @@ -64,10 +65,9 @@ namespace Duplicati.Library.Main.Operation return res; } - public void Run(string baseVersion, string compareVersion, IBackendManager backendManager, IEnumerable filterstrings, IFilter compositefilter, Action>> callback) + public async Task RunAsync(string baseVersion, string compareVersion, IBackendManager backendManager, IEnumerable filterstrings, IFilter compositefilter, Action>> callback) { - var cancellationToken = CancellationToken.None; - var filter = Library.Utility.JoinedFilterExpression.Join(new Library.Utility.FilterExpression(filterstrings), compositefilter); + var filter = JoinedFilterExpression.Join(new FilterExpression(filterstrings), compositefilter); var useLocalDb = !m_options.NoLocalDb && System.IO.File.Exists(m_options.Dbpath); baseVersion = string.IsNullOrEmpty(baseVersion) ? "1" : baseVersion; @@ -79,7 +79,7 @@ namespace Duplicati.Library.Main.Operation DateTime baseVersionTime; DateTime compareVersionTime; - using (var tmpdb = useLocalDb ? null : new Library.Utility.TempFile()) + using (var tmpdb = useLocalDb ? null : new TempFile()) using (var db = new Database.LocalListChangesDatabase(useLocalDb ? m_options.Dbpath : (string)tmpdb)) using (var storageKeeper = db.CreateStorageHelper()) { @@ -106,11 +106,11 @@ namespace Duplicati.Library.Main.Operation { Logging.Log.WriteInformationMessage(LOGTAG, "NoLocalDatabase", "No local database, accessing remote store"); - var parsedlist = (from n in backendManager.ListAsync(cancellationToken).Await() - let p = Volumes.VolumeBase.ParseFilename(n) - where p != null && p.FileType == RemoteVolumeType.Files - orderby p.Time descending - select p).ToArray(); + var parsedlist = (await backendManager.ListAsync(m_result.TaskControl.ProgressToken).ConfigureAwait(false)) + .Select(n => Volumes.VolumeBase.ParseFilename(n)) + .Where(p => p != null && p.FileType == RemoteVolumeType.Files) + .OrderByDescending(p => p.Time) + .ToArray(); var numberedList = parsedlist.Zip(Enumerable.Range(0, parsedlist.Length), (a, b) => new Tuple(b, a.Time, a)).ToList(); if (numberedList.Count < 2) @@ -138,19 +138,19 @@ namespace Duplicati.Library.Main.Operation } }; - if (!m_result.TaskControl.ProgressRendevouz().Await()) + if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) return; - using (var tmpfile = backendManager.GetAsync(baseFile.File.Name, null, baseFile.File.Size, cancellationToken).Await()) + using (var tmpfile = await backendManager.GetAsync(baseFile.File.Name, null, baseFile.File.Size, m_result.TaskControl.ProgressToken).ConfigureAwait(false)) using (var rd = new Volumes.FilesetVolumeReader(RestoreHandler.GetCompressionModule(baseFile.File.Name), tmpfile, m_options)) foreach (var f in rd.Files) if (FilterExpression.Matches(filter, f.Path)) storageKeeper.AddElement(f.Path, f.Hash, f.Metahash, f.Size, conv(f.Type), false); - if (!m_result.TaskControl.ProgressRendevouz().Await()) + if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) return; - using (var tmpfile = backendManager.GetAsync(compareFile.File.Name, null, compareFile.File.Size, cancellationToken).Await()) + using (var tmpfile = await backendManager.GetAsync(compareFile.File.Name, null, compareFile.File.Size, m_result.TaskControl.ProgressToken).ConfigureAwait(false)) using (var rd = new Volumes.FilesetVolumeReader(RestoreHandler.GetCompressionModule(compareFile.File.Name), tmpfile, m_options)) foreach (var f in rd.Files) if (FilterExpression.Matches(filter, f.Path)) diff --git a/Duplicati/Library/Main/Operation/ListControlFilesHandler.cs b/Duplicati/Library/Main/Operation/ListControlFilesHandler.cs index 27aa80653..a9f32c9b2 100644 --- a/Duplicati/Library/Main/Operation/ListControlFilesHandler.cs +++ b/Duplicati/Library/Main/Operation/ListControlFilesHandler.cs @@ -24,6 +24,7 @@ using System.Collections.Generic; using Duplicati.Library.Main.Database; using Duplicati.Library.Utility; using System.Threading; +using System.Threading.Tasks; namespace Duplicati.Library.Main.Operation { @@ -38,7 +39,7 @@ namespace Duplicati.Library.Main.Operation m_result = result; } - public void Run(IBackendManager backendManager, IEnumerable filterstrings, Library.Utility.IFilter compositefilter) + public async Task RunAsync(IBackendManager backendManager, IEnumerable filterstrings, IFilter compositefilter) { var cancellationToken = CancellationToken.None; using (var tmpdb = new TempFile()) @@ -50,7 +51,7 @@ namespace Duplicati.Library.Main.Operation try { - var filteredList = ListFilesHandler.ParseAndFilterFilesets(backendManager.ListAsync(cancellationToken).Await(), m_options); + var filteredList = ListFilesHandler.ParseAndFilterFilesets(await backendManager.ListAsync(cancellationToken).ConfigureAwait(false), m_options); if (filteredList.Count == 0) throw new Exception("No filesets found on remote target"); @@ -59,14 +60,14 @@ namespace Duplicati.Library.Main.Operation foreach (var fileversion in filteredList) try { - if (!m_result.TaskControl.ProgressRendevouz().Await()) + if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) return; var file = fileversion.Value.File; var entry = db.GetRemoteVolume(file.Name); var files = new List(); - using (var tmpfile = backendManager.GetAsync(file.Name, entry.Hash, entry.Size < 0 ? file.Size : entry.Size, cancellationToken).Await()) + using (var tmpfile = await backendManager.GetAsync(file.Name, entry.Hash, entry.Size < 0 ? file.Size : entry.Size, cancellationToken).ConfigureAwait(false)) using (var tmp = new Volumes.FilesetVolumeReader(RestoreHandler.GetCompressionModule(file.Name), tmpfile, m_options)) foreach (var cf in tmp.ControlFiles) if (Library.Utility.FilterExpression.Matches(filter, cf.Key)) @@ -88,7 +89,7 @@ namespace Duplicati.Library.Main.Operation } finally { - backendManager.WaitForEmptyAsync(db, null, cancellationToken).Await(); + await backendManager.WaitForEmptyAsync(db, null, cancellationToken).ConfigureAwait(false); } } } diff --git a/Duplicati/Library/Main/Operation/ListFilesHandler.cs b/Duplicati/Library/Main/Operation/ListFilesHandler.cs index dfdc414ed..cb59afec8 100644 --- a/Duplicati/Library/Main/Operation/ListFilesHandler.cs +++ b/Duplicati/Library/Main/Operation/ListFilesHandler.cs @@ -45,7 +45,7 @@ namespace Duplicati.Library.Main.Operation m_result = result; } - public async Task Run(IBackendManager backendManager, IEnumerable filterstrings, IFilter compositefilter) + public async Task RunAsync(IBackendManager backendManager, IEnumerable filterstrings, IFilter compositefilter) { var cancellationToken = m_result.TaskControl.ProgressToken; var parsedfilter = new FilterExpression(filterstrings); @@ -176,7 +176,7 @@ namespace Duplicati.Library.Main.Operation long flindex = 1; var filteredListMap = filteredList.ToDictionary(x => x.Value.File.Name, x => x.Value); - await foreach (var (tmpfile, hash, size, name) in backendManager.GetFilesOverlappedAsync(filteredList.Select(x => new RemoteVolumeMapper(x.Value)), cancellationToken)) + await foreach (var (tmpfile, hash, size, name) in backendManager.GetFilesOverlappedAsync(filteredList.Select(x => new RemoteVolumeMapper(x.Value)), cancellationToken).ConfigureAwait(false)) { var flentry = filteredListMap[name]; using (tmpfile) @@ -237,7 +237,7 @@ namespace Duplicati.Library.Main.Operation { var list = new List(); var map = filteredList.ToDictionary(x => x.Value.File.Name, x => x); - await foreach (var (file, hash, size, name) in backendManager.GetFilesOverlappedAsync(filteredList.Select(x => new RemoteVolumeMapper(x.Value)), cancelToken)) + await foreach (var (file, hash, size, name) in backendManager.GetFilesOverlappedAsync(filteredList.Select(x => new RemoteVolumeMapper(x.Value)), cancelToken).ConfigureAwait(false)) { // We must obtain the partial/full status from the fileset file in the dlist files. // Without this, the restore dialog will show all versions as full, or all versions diff --git a/Duplicati/Library/Main/Operation/PurgeBrokenFilesHandler.cs b/Duplicati/Library/Main/Operation/PurgeBrokenFilesHandler.cs index f4487c6d8..e2c281990 100644 --- a/Duplicati/Library/Main/Operation/PurgeBrokenFilesHandler.cs +++ b/Duplicati/Library/Main/Operation/PurgeBrokenFilesHandler.cs @@ -1,28 +1,30 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Duplicati.Library.Interface; +using Duplicati.Library.Main.Database; namespace Duplicati.Library.Main.Operation { @@ -41,7 +43,7 @@ namespace Duplicati.Library.Main.Operation m_result = result; } - public void Run(IBackendManager backendManager, Library.Utility.IFilter filter) + public async Task RunAsync(IBackendManager backendManager, Library.Utility.IFilter filter) { if (!System.IO.File.Exists(m_options.Dbpath)) throw new UserInformationException(string.Format("Database file does not exist: {0}", m_options.Dbpath), "DatabaseDoesNotExist"); @@ -49,15 +51,13 @@ namespace Duplicati.Library.Main.Operation if (filter != null && !filter.Empty) throw new UserInformationException("Filters are not supported for this operation", "FiltersNotAllowedOnPurgeBrokenFiles"); - List missing = null; - - using (var db = new Database.LocalListBrokenFilesDatabase(m_options.Dbpath)) + using (var db = new LocalListBrokenFilesDatabase(m_options.Dbpath)) using (var tr = db.BeginTransaction()) { if (db.PartiallyRecreated) throw new UserInformationException("The command does not work on partially recreated databases", "CannotPurgeOnPartialDatabase"); - var sets = ListBrokenFilesHandler.GetBrokenFilesetsFromRemote(backendManager, m_result, db, tr, m_options, out missing); + (var sets, var missing) = await ListBrokenFilesHandler.GetBrokenFilesetsFromRemote(backendManager, m_result, db, tr, m_options).ConfigureAwait(false); if (sets == null) return; @@ -105,35 +105,21 @@ namespace Duplicati.Library.Main.Operation m_result.DeleteResults = new DeleteResults(m_result); using (var rmdb = new Database.LocalDeleteDatabase(db)) + using (var deltr = new ReusableTransaction(rmdb)) { - var deltr = rmdb.BeginTransaction(); - try + var opts = new Options(new Dictionary(m_options.RawOptions)); + opts.RawOptions["version"] = string.Join(",", fully_emptied.Select(x => x.Version.ToString())); + opts.RawOptions.Remove("time"); + opts.RawOptions["no-auto-compact"] = "true"; + + await new DeleteHandler(opts, (DeleteResults)m_result.DeleteResults) + .DoRunAsync(rmdb, deltr, true, false, backendManager).ConfigureAwait(false); + + if (!m_options.Dryrun) { - var opts = new Options(new Dictionary(m_options.RawOptions)); - opts.RawOptions["version"] = string.Join(",", fully_emptied.Select(x => x.Version.ToString())); - opts.RawOptions.Remove("time"); - opts.RawOptions["no-auto-compact"] = "true"; - - new DeleteHandler(opts, (DeleteResults)m_result.DeleteResults) - .DoRun(rmdb, ref deltr, true, false, backendManager); - - if (!m_options.Dryrun) - { - using (new Logging.Timer(LOGTAG, "CommitDelete", "CommitDelete")) - deltr.Commit(); - - rmdb.WriteResults(); - } - else - deltr.Rollback(); + deltr.Commit("CommitDelete", restart: false); + rmdb.WriteResults(); } - finally - { - if (deltr != null) - try { deltr.Rollback(); } - catch { } - } - } pgoffset += (pgspan * fully_emptied.Length); @@ -161,12 +147,12 @@ namespace Duplicati.Library.Main.Operation opts.RawOptions.Remove("time"); opts.RawOptions["no-auto-compact"] = "true"; - new PurgeFilesHandler(opts, (PurgeFilesResults)m_result.PurgeResults).Run(backendManager, pgdb, pgoffset, pgspan, (cmd, filesetid, tablename) => + await new PurgeFilesHandler(opts, (PurgeFilesResults)m_result.PurgeResults).RunAsync(backendManager, pgdb, pgoffset, pgspan, (cmd, filesetid, tablename) => { if (filesetid != bs.FilesetID) throw new Exception(string.Format("Unexpected filesetid: {0}, expected {1}", filesetid, bs.FilesetID)); db.InsertBrokenFileIDsIntoTable(filesetid, tablename, "FileID", cmd.Transaction); - }); + }).ConfigureAwait(false); } pgoffset += pgspan; diff --git a/Duplicati/Library/Main/Operation/PurgeFilesHandler.cs b/Duplicati/Library/Main/Operation/PurgeFilesHandler.cs index a405d2a2b..51ae1296c 100644 --- a/Duplicati/Library/Main/Operation/PurgeFilesHandler.cs +++ b/Duplicati/Library/Main/Operation/PurgeFilesHandler.cs @@ -22,6 +22,7 @@ using System; using System.Linq; using System.Threading; +using System.Threading.Tasks; using Duplicati.Library.Interface; using Duplicati.Library.Utility; @@ -42,7 +43,7 @@ namespace Duplicati.Library.Main.Operation m_result = result; } - public void Run(IBackendManager backendManager, Library.Utility.IFilter filter) + public async Task RunAsync(IBackendManager backendManager, IFilter filter) { if (filter == null || filter.Empty) throw new UserInformationException("Cannot purge with an empty filter, as that would cause all files to be removed.\nTo remove an entire backup set, use the \"delete\" command.", "EmptyFilterPurgeNotAllowed"); @@ -51,17 +52,14 @@ namespace Duplicati.Library.Main.Operation throw new UserInformationException(string.Format("Database file does not exist: {0}", m_options.Dbpath), "DatabaseDoesNotExist"); using (var db = new Database.LocalPurgeDatabase(m_options.Dbpath)) - DoRun(backendManager, db, filter, null, 0, 1); + await DoRunAsync(backendManager, db, filter, null, 0, 1).ConfigureAwait(false); } - public void Run(IBackendManager backendManager, Database.LocalPurgeDatabase db, float pgoffset, float pgspan, Action filtercommand) - { - DoRun(backendManager, db, null, filtercommand, pgoffset, pgspan); - } + public Task RunAsync(IBackendManager backendManager, Database.LocalPurgeDatabase db, float pgoffset, float pgspan, Action filtercommand) + => DoRunAsync(backendManager, db, null, filtercommand, pgoffset, pgspan); - private void DoRun(IBackendManager backendManager, Database.LocalPurgeDatabase db, IFilter filter, Action filtercommand, float pgoffset, float pgspan) + private async Task DoRunAsync(IBackendManager backendManager, Database.LocalPurgeDatabase db, IFilter filter, Action filtercommand, float pgoffset, float pgspan) { - var cancellationToken = CancellationToken.None; m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.PurgeFiles_Begin); Logging.Log.WriteInformationMessage(LOGTAG, "StartingPurge", "Starting purge operation"); @@ -89,9 +87,9 @@ namespace Duplicati.Library.Main.Operation db.VerifyConsistency(m_options.Blocksize, m_options.BlockhashSize, false, null); if (m_options.NoBackendverification) - FilelistProcessor.VerifyLocalList(backendManager, db, cancellationToken).Await(); + await FilelistProcessor.VerifyLocalList(backendManager, db, m_result.TaskControl.ProgressToken).ConfigureAwait(false); else - FilelistProcessor.VerifyRemoteList(backendManager, m_options, db, m_result.BackendWriter, null).Await(); + await FilelistProcessor.VerifyRemoteList(backendManager, m_options, db, m_result.BackendWriter, null, null, logErrors: true, verifyMode: FilelistProcessor.VerifyMode.VerifyStrict).ConfigureAwait(false); } var filesets = db.FilesetTimes.OrderByDescending(x => x.Value).ToArray(); @@ -103,6 +101,10 @@ namespace Duplicati.Library.Main.Operation m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.PurgeFiles_Process); m_result.OperationProgressUpdater.UpdateProgress(currentprogress); + // If we crash now, it is possible that the remote storage contains partial files + if (!m_options.Dryrun) + db.TerminatedWithActiveUploads = true; + // Reverse makes sure we re-write the old versions first foreach (var versionid in versions.Reverse()) { @@ -208,9 +210,9 @@ namespace Duplicati.Library.Main.Operation db.UpdateRemoteVolume(f.Key, RemoteVolumeState.Deleting, f.Value, null, tr); tr.Commit(); - backendManager.PutAsync(vol, null, null, true, cancellationToken).Await(); - backendManager.DeleteAsync(prevfilename, -1, true, cancellationToken).Await(); - backendManager.WaitForEmptyAsync(db, null, cancellationToken).Await(); + await backendManager.PutAsync(vol, null, null, true, m_result.TaskControl.ProgressToken).ConfigureAwait(false); + await backendManager.DeleteAsync(prevfilename, -1, true, m_result.TaskControl.ProgressToken).ConfigureAwait(false); + await backendManager.WaitForEmptyAsync(db, null, m_result.TaskControl.ProgressToken).ConfigureAwait(false); } } } @@ -221,6 +223,8 @@ namespace Duplicati.Library.Main.Operation m_result.OperationProgressUpdater.UpdateProgress(currentprogress); } + if (!m_options.Dryrun) + db.TerminatedWithActiveUploads = false; if (doCompactStep) { @@ -234,30 +238,21 @@ namespace Duplicati.Library.Main.Operation m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.PurgeFiles_Compact); m_result.CompactResults = new CompactResults(m_result); using (var cdb = new Database.LocalDeleteDatabase(db)) + using (var ctr = new Database.ReusableTransaction(cdb)) { - var tr = cdb.BeginTransaction(); - try - { - var (_, tr2) = new CompactHandler(m_options, (CompactResults)m_result.CompactResults).DoCompact(cdb, true, tr, backendManager).Await(); - tr2 = tr; - } - catch - { - try { tr.Rollback(); } - catch { } - } - finally - { - try { tr.Commit(); } - catch { } - } + await new CompactHandler(m_options, (CompactResults)m_result.CompactResults) + .DoCompactAsync(cdb, true, ctr, backendManager) + .ConfigureAwait(false); + + ctr.Commit(restart: false); + cdb.WriteResults(); } } m_result.OperationProgressUpdater.UpdateProgress(pgoffset + pgspan); m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.PurgeFiles_Complete); - backendManager.WaitForEmptyAsync(db, null, cancellationToken).Await(); + await backendManager.WaitForEmptyAsync(db, null, m_result.TaskControl.ProgressToken).ConfigureAwait(false); } } } diff --git a/Duplicati/Library/Main/Operation/RecreateDatabaseHandler.cs b/Duplicati/Library/Main/Operation/RecreateDatabaseHandler.cs index ebfc4f2e8..f449adfe1 100644 --- a/Duplicati/Library/Main/Operation/RecreateDatabaseHandler.cs +++ b/Duplicati/Library/Main/Operation/RecreateDatabaseHandler.cs @@ -20,6 +20,8 @@ // DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; +using System.Data; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -57,15 +59,15 @@ namespace Duplicati.Library.Main.Operation /// A filter that can be used to disregard certain remote files, intended to be used to select a certain filelist /// Filters the files in a filelist to prevent downloading unwanted data /// A callback hook that can be used to work with downloaded block volumes, intended to be use to recover data blocks while processing blocklists - public void Run(string path, IBackendManager backendManager, IFilter filter, NumberedFilterFilelistDelegate filelistfilter, BlockVolumePostProcessor blockprocessor) + public async Task RunAsync(string path, IBackendManager backendManager, IFilter filter, NumberedFilterFilelistDelegate filelistfilter, BlockVolumePostProcessor blockprocessor) { - if (System.IO.File.Exists(path)) + if (File.Exists(path)) throw new UserInformationException(string.Format("Cannot recreate database because file already exists: {0}", path), "RecreateTargetDatabaseExists"); using (var db = new LocalDatabase(path, "Recreate", true)) { m_result.SetDatabase(db); - DoRun(backendManager, db, false, filter, filelistfilter, blockprocessor).Await(); + await DoRunAsync(backendManager, db, false, filter, filelistfilter, blockprocessor).ConfigureAwait(false); db.WriteResults(); } } @@ -76,7 +78,7 @@ namespace Duplicati.Library.Main.Operation /// A filter that can be used to disregard certain remote files, intended to be used to select a certain filelist /// Filters the files in a filelist to prevent downloading unwanted data /// A callback hook that can be used to work with downloaded block volumes, intended to be use to recover data blocks while processing blocklists - public void RunUpdate(IBackendManager backendManager, Library.Utility.IFilter filter, NumberedFilterFilelistDelegate filelistfilter, BlockVolumePostProcessor blockprocessor) + public async Task RunUpdateAsync(IBackendManager backendManager, Library.Utility.IFilter filter, NumberedFilterFilelistDelegate filelistfilter, BlockVolumePostProcessor blockprocessor) { if (!m_options.RepairOnlyPaths) throw new UserInformationException(string.Format("Can only update with paths, try setting --{0}", "repair-only-paths"), "RepairUpdateRequiresPathsOnly"); @@ -98,7 +100,7 @@ namespace Duplicati.Library.Main.Operation if (preexistingOptionsInDatabase) Utility.VerifyOptionsAndUpdateDatabase(db, m_options, null); - DoRun(backendManager, db, true, filter, filelistfilter, blockprocessor).Await(); + await DoRunAsync(backendManager, db, true, filter, filelistfilter, blockprocessor).ConfigureAwait(false); db.WriteResults(); } } @@ -111,9 +113,8 @@ namespace Duplicati.Library.Main.Operation /// A filter that can be used to disregard certain remote files, intended to be used to select a certain filelist /// Filters the files in a filelist to prevent downloading unwanted data /// A callback hook that can be used to work with downloaded block volumes, intended to be use to recover data blocks while processing blocklists - internal async Task DoRun(IBackendManager backendManager, LocalDatabase dbparent, bool updating, IFilter filter = null, NumberedFilterFilelistDelegate filelistfilter = null, BlockVolumePostProcessor blockprocessor = null) + internal async Task DoRunAsync(IBackendManager backendManager, LocalDatabase dbparent, bool updating, IFilter filter = null, NumberedFilterFilelistDelegate filelistfilter = null, BlockVolumePostProcessor blockprocessor = null) { - var cancellationToken = CancellationToken.None; m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Recreate_Running); //We build a local database in steps. @@ -128,7 +129,7 @@ namespace Duplicati.Library.Main.Operation expRecreateDb = true; } - var rawlist = await backendManager.ListAsync(cancellationToken); + var rawlist = await backendManager.ListAsync(m_result.TaskControl.ProgressToken).ConfigureAwait(false); //First step is to examine the remote storage to see what // kind of data we can find @@ -204,17 +205,14 @@ namespace Duplicati.Library.Main.Operation } var isFirstFilelist = true; - var blocksize = m_options.Blocksize; - var hashes_pr_block = blocksize / m_options.BlockhashSize; - - await foreach (var (tmpfile, hash, size, name) in backendManager.GetFilesOverlappedAsync(filelistWork, m_result.TaskControl.ProgressToken)) + await foreach (var (tmpfile, hash, size, name) in backendManager.GetFilesOverlappedAsync(filelistWork, m_result.TaskControl.ProgressToken).ConfigureAwait(false)) { var entry = new RemoteVolume(name, hash, size); try { if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) { - await backendManager.WaitForEmptyAsync(restoredb, tr, cancellationToken).ConfigureAwait(false); + await backendManager.WaitForEmptyAsync(restoredb, tr, m_result.TaskControl.ProgressToken).ConfigureAwait(false); m_result.EndTime = DateTime.UtcNow; return; } @@ -239,87 +237,21 @@ namespace Duplicati.Library.Main.Operation var parsed = VolumeBase.ParseFilename(entry.Name); + using var stream = new FileStream(tmpfile, FileMode.Open, FileAccess.Read, FileShare.Read); + using var compressor = DynamicLoader.CompressionLoader.GetModule(parsed.CompressionModule, stream, ArchiveMode.Read, m_options.RawOptions); + if (compressor == null) + throw new UserInformationException(string.Format("Failed to load compression module: {0}", parsed.CompressionModule), "FailedToLoadCompressionModule"); + if (!hasUpdatedOptions) { - VolumeReaderBase.UpdateOptionsFromManifest(parsed.CompressionModule, tmpfile, m_options); + VolumeReaderBase.UpdateOptionsFromManifest(compressor, m_options); hasUpdatedOptions = true; - // Recompute the cached sizes - blocksize = m_options.Blocksize; - hashes_pr_block = blocksize / m_options.BlockhashSize; } // Create timestamped operations based on the file timestamp var filesetid = restoredb.CreateFileset(volumeIds[entry.Name], parsed.Time, tr); - // retrieve fileset data from dlist - var filesetData = VolumeReaderBase.GetFilesetData(parsed.CompressionModule, tmpfile, m_options); - - // update fileset using filesetData - restoredb.UpdateFullBackupStateInFileset(filesetid, filesetData.IsFullBackup); - - using (var filelistreader = new FilesetVolumeReader(parsed.CompressionModule, tmpfile, m_options)) - foreach (var fe in filelistreader.Files.Where(x => Library.Utility.FilterExpression.Matches(filter, x.Path))) - { - try - { - var expectedmetablocks = (fe.Metasize + blocksize - 1) / blocksize; - var expectedmetablocklisthashes = (expectedmetablocks + hashes_pr_block - 1) / hashes_pr_block; - if (expectedmetablocks <= 1) expectedmetablocklisthashes = 0; - - var metadataid = long.MinValue; - var split = Database.LocalDatabase.SplitIntoPrefixAndName(fe.Path); - var prefixid = restoredb.GetOrCreatePathPrefix(split.Key, tr); - - switch (fe.Type) - { - case FilelistEntryType.Folder: - metadataid = restoredb.AddMetadataset(fe.Metahash, fe.Metasize, fe.MetaBlocklistHashes, expectedmetablocklisthashes, tr); - restoredb.AddDirectoryEntry(filesetid, prefixid, split.Value, fe.Time, metadataid, tr); - break; - case FilelistEntryType.File: - var expectedblocks = (fe.Size + blocksize - 1) / blocksize; - var expectedblocklisthashes = (expectedblocks + hashes_pr_block - 1) / hashes_pr_block; - if (expectedblocks <= 1) expectedblocklisthashes = 0; - - var blocksetid = restoredb.AddBlockset(fe.Hash, fe.Size, fe.BlocklistHashes, expectedblocklisthashes, tr); - metadataid = restoredb.AddMetadataset(fe.Metahash, fe.Metasize, fe.MetaBlocklistHashes, expectedmetablocklisthashes, tr); - restoredb.AddFileEntry(filesetid, prefixid, split.Value, fe.Time, blocksetid, metadataid, tr); - - if (fe.Size <= blocksize) - { - if (!string.IsNullOrWhiteSpace(fe.Blockhash)) - restoredb.AddSmallBlocksetLink(fe.Hash, fe.Blockhash, fe.Blocksize, tr); - else if (m_options.BlockHashAlgorithm == m_options.FileHashAlgorithm) - restoredb.AddSmallBlocksetLink(fe.Hash, fe.Hash, fe.Size, tr); - else if (fe.Size > 0) - Logging.Log.WriteWarningMessage(LOGTAG, "MissingBlockHash", null, "No block hash found for file: {0}", fe.Path); - } - - break; - case FilelistEntryType.Symlink: - metadataid = restoredb.AddMetadataset(fe.Metahash, fe.Metasize, fe.MetaBlocklistHashes, expectedmetablocklisthashes, tr); - restoredb.AddSymlinkEntry(filesetid, prefixid, split.Value, fe.Time, metadataid, tr); - break; - default: - Logging.Log.WriteWarningMessage(LOGTAG, "SkippingUnknownFileEntry", null, "Skipping file-entry with unknown type {0}: {1} ", fe.Type, fe.Path); - break; - } - - if (fe.Metasize <= blocksize && (fe.Type == FilelistEntryType.Folder || fe.Type == FilelistEntryType.File || fe.Type == FilelistEntryType.Symlink)) - { - if (!string.IsNullOrWhiteSpace(fe.Metablockhash)) - restoredb.AddSmallBlocksetLink(fe.Metahash, fe.Metablockhash, fe.Metasize, tr); - else if (m_options.BlockHashAlgorithm == m_options.FileHashAlgorithm) - restoredb.AddSmallBlocksetLink(fe.Metahash, fe.Metahash, fe.Metasize, tr); - else - Logging.Log.WriteWarningMessage(LOGTAG, "MissingMetadataBlockHash", null, "No block hash found for file metadata: {0}", fe.Path); - } - } - catch (Exception ex) - { - Logging.Log.WriteWarningMessage(LOGTAG, "FileEntryProcessingFailed", ex, "Failed to process file-entry: {0}", fe.Path); - } - } + RecreateFilesetFromRemoteList(restoredb, tr, compressor, filesetid, m_options, filter); } } catch (Exception ex) @@ -372,13 +304,13 @@ namespace Duplicati.Library.Main.Operation var progress = 0; - await foreach (var (tmpfile, hash, size, name) in backendManager.GetFilesOverlappedAsync(indexfiles, m_result.TaskControl.ProgressToken)) + await foreach (var (tmpfile, hash, size, name) in backendManager.GetFilesOverlappedAsync(indexfiles, m_result.TaskControl.ProgressToken).ConfigureAwait(false)) { try { if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) { - await backendManager.WaitForEmptyAsync(restoredb, tr, cancellationToken).ConfigureAwait(false); + await backendManager.WaitForEmptyAsync(restoredb, tr, m_result.TaskControl.ProgressToken).ConfigureAwait(false); m_result.EndTime = DateTime.UtcNow; return; } @@ -510,7 +442,7 @@ namespace Duplicati.Library.Main.Operation } var progress = 0; - await foreach (var (tmpfile, hash, size, name) in backendManager.GetFilesOverlappedAsync(lst, m_result.TaskControl.ProgressToken)) + await foreach (var (tmpfile, hash, size, name) in backendManager.GetFilesOverlappedAsync(lst, m_result.TaskControl.ProgressToken).ConfigureAwait(false)) { try { @@ -518,9 +450,9 @@ namespace Duplicati.Library.Main.Operation using (var rd = new BlockVolumeReader(RestoreHandler.GetCompressionModule(name), tmpfile, m_options)) using (var tr = restoredb.BeginTransaction()) { - if (!m_result.TaskControl.ProgressRendevouz().Await()) + if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) { - backendManager.WaitForEmptyAsync(restoredb, tr, cancellationToken).Await(); + await backendManager.WaitForEmptyAsync(restoredb, tr, m_result.TaskControl.ProgressToken).ConfigureAwait(false); m_result.EndTime = DateTime.UtcNow; return; } @@ -584,7 +516,7 @@ namespace Duplicati.Library.Main.Operation } } - backendManager.WaitForEmptyAsync(restoredb, null, cancellationToken).Await(); + await backendManager.WaitForEmptyAsync(restoredb, null, m_result.TaskControl.ProgressToken).ConfigureAwait(false); if (!m_options.RepairOnlyPaths) { @@ -627,6 +559,82 @@ namespace Duplicati.Library.Main.Operation } } + public static void RecreateFilesetFromRemoteList(LocalRecreateDatabase restoredb, IDbTransaction transaction, ICompression compressor, long filesetid, Options options, IFilter filter) + { + var blocksize = options.Blocksize; + var hashes_pr_block = blocksize / options.BlockhashSize; + + // retrieve fileset data from dlist + var filesetData = VolumeReaderBase.GetFilesetData(compressor, options); + + // update fileset using filesetData + restoredb.UpdateFullBackupStateInFileset(filesetid, filesetData.IsFullBackup, transaction); + + using (var filelistreader = new FilesetVolumeReader(compressor, options)) + foreach (var fe in filelistreader.Files.Where(x => Library.Utility.FilterExpression.Matches(filter, x.Path))) + { + try + { + var expectedmetablocks = (fe.Metasize + blocksize - 1) / blocksize; + var expectedmetablocklisthashes = (expectedmetablocks + hashes_pr_block - 1) / hashes_pr_block; + if (expectedmetablocks <= 1) expectedmetablocklisthashes = 0; + + var metadataid = long.MinValue; + var split = LocalDatabase.SplitIntoPrefixAndName(fe.Path); + var prefixid = restoredb.GetOrCreatePathPrefix(split.Key, transaction); + + switch (fe.Type) + { + case FilelistEntryType.Folder: + metadataid = restoredb.AddMetadataset(fe.Metahash, fe.Metasize, fe.MetaBlocklistHashes, expectedmetablocklisthashes, transaction); + restoredb.AddDirectoryEntry(filesetid, prefixid, split.Value, fe.Time, metadataid, transaction); + break; + case FilelistEntryType.File: + var expectedblocks = (fe.Size + blocksize - 1) / blocksize; + var expectedblocklisthashes = (expectedblocks + hashes_pr_block - 1) / hashes_pr_block; + if (expectedblocks <= 1) expectedblocklisthashes = 0; + + var blocksetid = restoredb.AddBlockset(fe.Hash, fe.Size, fe.BlocklistHashes, expectedblocklisthashes, transaction); + metadataid = restoredb.AddMetadataset(fe.Metahash, fe.Metasize, fe.MetaBlocklistHashes, expectedmetablocklisthashes, transaction); + restoredb.AddFileEntry(filesetid, prefixid, split.Value, fe.Time, blocksetid, metadataid, transaction); + + if (fe.Size <= blocksize) + { + if (!string.IsNullOrWhiteSpace(fe.Blockhash)) + restoredb.AddSmallBlocksetLink(fe.Hash, fe.Blockhash, fe.Blocksize, transaction); + else if (options.BlockHashAlgorithm == options.FileHashAlgorithm) + restoredb.AddSmallBlocksetLink(fe.Hash, fe.Hash, fe.Size, transaction); + else if (fe.Size > 0) + Logging.Log.WriteWarningMessage(LOGTAG, "MissingBlockHash", null, "No block hash found for file: {0}", fe.Path); + } + + break; + case FilelistEntryType.Symlink: + metadataid = restoredb.AddMetadataset(fe.Metahash, fe.Metasize, fe.MetaBlocklistHashes, expectedmetablocklisthashes, transaction); + restoredb.AddSymlinkEntry(filesetid, prefixid, split.Value, fe.Time, metadataid, transaction); + break; + default: + Logging.Log.WriteWarningMessage(LOGTAG, "SkippingUnknownFileEntry", null, "Skipping file-entry with unknown type {0}: {1} ", fe.Type, fe.Path); + break; + } + + if (fe.Metasize <= blocksize && (fe.Type == FilelistEntryType.Folder || fe.Type == FilelistEntryType.File || fe.Type == FilelistEntryType.Symlink)) + { + if (!string.IsNullOrWhiteSpace(fe.Metablockhash)) + restoredb.AddSmallBlocksetLink(fe.Metahash, fe.Metablockhash, fe.Metasize, transaction); + else if (options.BlockHashAlgorithm == options.FileHashAlgorithm) + restoredb.AddSmallBlocksetLink(fe.Metahash, fe.Metahash, fe.Metasize, transaction); + else + Logging.Log.WriteWarningMessage(LOGTAG, "MissingMetadataBlockHash", null, "No block hash found for file metadata: {0}", fe.Path); + } + } + catch (Exception ex) + { + Logging.Log.WriteWarningMessage(LOGTAG, "FileEntryProcessingFailed", ex, "Failed to process file-entry: {0}", fe.Path); + } + } + } + /// /// Look in the database for filenames similar to the current filename, but with a different compression and encryption module /// diff --git a/Duplicati/Library/Main/Operation/RepairHandler.cs b/Duplicati/Library/Main/Operation/RepairHandler.cs index be4083989..ea405ef1c 100644 --- a/Duplicati/Library/Main/Operation/RepairHandler.cs +++ b/Duplicati/Library/Main/Operation/RepairHandler.cs @@ -20,6 +20,7 @@ // DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -48,11 +49,11 @@ namespace Duplicati.Library.Main.Operation throw new UserInformationException(Strings.Common.PassphraseChangeUnsupported, "PassphraseChangeUnsupported"); } - public void Run(IBackendManager backendManager, IFilter filter) + public async Task RunAsync(IBackendManager backendManager, IFilter filter) { - if (!System.IO.File.Exists(m_options.Dbpath)) + if (!File.Exists(m_options.Dbpath)) { - RunRepairLocal(backendManager, filter); + await RunRepairLocalAsync(backendManager, filter).ConfigureAwait(false); RunRepairCommon(); m_result.EndTime = DateTime.UtcNow; return; @@ -77,47 +78,48 @@ namespace Duplicati.Library.Main.Operation } else { - var baseName = System.IO.Path.ChangeExtension(m_options.Dbpath, "backup"); + var baseName = Path.ChangeExtension(m_options.Dbpath, "backup"); var i = 0; - while (System.IO.File.Exists(baseName) && i++ < 1000) - baseName = System.IO.Path.ChangeExtension(m_options.Dbpath, "backup-" + i.ToString()); + while (File.Exists(baseName) && i++ < 1000) + baseName = Path.ChangeExtension(m_options.Dbpath, "backup-" + i.ToString()); Logging.Log.WriteInformationMessage(LOGTAG, "RenamingDatabase", "Renaming existing db from {0} to {1}", m_options.Dbpath, baseName); - System.IO.File.Move(m_options.Dbpath, baseName); + File.Move(m_options.Dbpath, baseName); } - RunRepairLocal(backendManager, filter); + await RunRepairLocalAsync(backendManager, filter).ConfigureAwait(false); RunRepairCommon(); } else { RunRepairCommon(); - RunRepairRemote(backendManager, m_result.TaskControl.ProgressToken).Await(); + await RunRepairRemoteAsync(backendManager, m_result.TaskControl.ProgressToken).ConfigureAwait(false); } m_result.EndTime = DateTime.UtcNow; } - public void RunRepairLocal(IBackendManager backendManager, Library.Utility.IFilter filter) + public async Task RunRepairLocalAsync(IBackendManager backendManager, IFilter filter) { m_result.RecreateDatabaseResults = new RecreateDatabaseResults(m_result); using (new Logging.Timer(LOGTAG, "RecreateDbForRepair", "Recreate database for repair")) - using (var f = m_options.Dryrun ? new Library.Utility.TempFile() : null) + using (var f = m_options.Dryrun ? new TempFile() : null) { - if (f != null && System.IO.File.Exists(f)) - System.IO.File.Delete(f); + if (f != null && File.Exists(f)) + File.Delete(f); var filelistfilter = RestoreHandler.FilterNumberedFilelist(m_options.Time, m_options.Version); - new RecreateDatabaseHandler(m_options, (RecreateDatabaseResults)m_result.RecreateDatabaseResults) - .Run(m_options.Dryrun ? (string)f : m_options.Dbpath, backendManager, filter, filelistfilter, null); + await new RecreateDatabaseHandler(m_options, (RecreateDatabaseResults)m_result.RecreateDatabaseResults) + .RunAsync(m_options.Dryrun ? (string)f : m_options.Dbpath, backendManager, filter, filelistfilter, null) + .ConfigureAwait(false); } } - public async Task RunRepairRemote(IBackendManager backendManager, CancellationToken cancellationToken) + public async Task RunRepairRemoteAsync(IBackendManager backendManager, CancellationToken cancellationToken) { - if (!System.IO.File.Exists(m_options.Dbpath)) + if (!File.Exists(m_options.Dbpath)) throw new UserInformationException(string.Format("Database file does not exist: {0}", m_options.Dbpath), "RepairDatabaseFileDoesNotExist"); m_result.OperationProgressUpdater.UpdateProgress(0); @@ -134,12 +136,30 @@ namespace Duplicati.Library.Main.Operation if (db.RepairInProgress) throw new UserInformationException("The database was attempted repaired, but the repair did not complete. This database may be incomplete and the repair process is not allowed to alter remote files as that could result in data loss.", "DatabaseIsInRepairState"); - var tp = await FilelistProcessor.RemoteListAnalysis(backendManager, m_options, db, m_result.BackendWriter, null).ConfigureAwait(false); + // If the last backup failed, guard the incomplete fileset, so we can create a synthetic filelist + var lastTempFilelist = db.GetLastIncompleteFilesetVolume(null); + var tp = await FilelistProcessor.RemoteListAnalysis(backendManager, m_options, db, m_result.BackendWriter, [lastTempFilelist.Name], null, FilelistProcessor.VerifyMode.VerifyAndCleanForced).ConfigureAwait(false); + var buffer = new byte[m_options.Blocksize]; var hashsize = HashFactory.HashSizeBytes(m_options.BlockHashAlgorithm); + var missingRemoteFilesets = db.MissingRemoteFilesets().ToList(); + var missingLocalFilesets = db.MissingLocalFilesets().ToList(); + var progress = 0; - var targetProgess = tp.ExtraVolumes.Count() + tp.MissingVolumes.Count() + tp.VerificationRequiredVolumes.Count(); + var targetProgess = tp.ExtraVolumes.Count() + tp.MissingVolumes.Count() + tp.VerificationRequiredVolumes.Count() + missingRemoteFilesets.Count + missingLocalFilesets.Count; + + var mostRecentLocal = db.FilesetTimes.Select(x => x.Value.ToLocalTime()).Append(DateTime.MinValue).Max(); + var mostRecentRemote = tp.ParsedVolumes.Select(x => x.Time.ToLocalTime()).Append(DateTime.MinValue).Max(); + if (mostRecentLocal < DateTime.UnixEpoch) + throw new UserInformationException("The local database has no fileset times. Consider deleting the local database and run the repair operation again.", "LocalDatabaseHasNoFilesetTimes"); + if (mostRecentRemote > mostRecentLocal) + { + if (m_options.RepairIgnoreOutdatedDatabase) + Logging.Log.WriteWarningMessage(LOGTAG, "RemoteFilesNewerThanLocalDatabase", null, "The remote files are newer ({0}) than the local database ({1}), this is likely because the database is outdated. Continuing as the options force ignoring this.", mostRecentRemote, mostRecentLocal); + else + throw new UserInformationException($"The remote files are newer ({mostRecentRemote}) than the local database ({mostRecentLocal}), this is likely because the database is outdated. Consider deleting the local database and run the repair operation again. If this is expected, set the option \"--repair-ignore-outdated-database\" ", "RemoteFilesNewerThanLocalDatabase"); + } if (m_options.Dryrun) { @@ -156,7 +176,7 @@ namespace Duplicati.Library.Main.Operation } } - if (tp.ExtraVolumes.Any() || tp.MissingVolumes.Any() || tp.VerificationRequiredVolumes.Any()) + if (tp.ExtraVolumes.Any() || tp.MissingVolumes.Any() || tp.VerificationRequiredVolumes.Any() || missingRemoteFilesets.Any() || missingLocalFilesets.Any()) { if (tp.VerificationRequiredVolumes.Any()) { @@ -290,6 +310,69 @@ namespace Duplicati.Library.Main.Operation throw new UserInformationException($"The backup storage destination is missing data files. You can either enable `--rebuild-missing-dblock-files` or run the purge command to remove these files. The following files are missing: {string.Join(", ", missingDblocks.Select(x => x.Name))}", "MissingDblockFiles"); } + var anyDlistUploads = false; + foreach (var (filesetId, timestamp, isfull) in missingRemoteFilesets) + { + if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) + { + await backendManager.WaitForEmptyAsync(db, null, cancellationToken).ConfigureAwait(false); + return; + } + + progress++; + m_result.OperationProgressUpdater.UpdateProgress((float)progress / targetProgess); + var fileTime = FilesetVolumeWriter.ProbeUnusedFilenameName(db, m_options, timestamp); + + var fsw = new FilesetVolumeWriter(m_options, fileTime); + Logging.Log.WriteInformationMessage(LOGTAG, "ReuploadingFileset", "Re-uploading fileset {0} from {1} as remote volume registration is missing, new filename: {2}", filesetId, timestamp, fsw.RemoteFilename); + + if (!string.IsNullOrEmpty(m_options.ControlFiles)) + foreach (var p in m_options.ControlFiles.Split(new char[] { System.IO.Path.PathSeparator }, StringSplitOptions.RemoveEmptyEntries)) + fsw.AddControlFile(p, m_options.GetCompressionHintFromFilename(p)); + + fsw.CreateFilesetFile(isfull); + db.WriteFileset(fsw, filesetId, null); + fsw.Close(); + + if (m_options.Dryrun) + { + fsw.Dispose(); + Logging.Log.WriteDryrunMessage(LOGTAG, "WouldReUploadFileset", "would re-upload fileset {0}", fsw.RemoteFilename); + continue; + } + + fsw.VolumeID = db.RegisterRemoteVolume(fsw.RemoteFilename, RemoteVolumeType.Files, -1, RemoteVolumeState.Temporary); + db.LinkFilesetToVolume(filesetId, fsw.VolumeID, null); + await backendManager.PutAsync(fsw, null, null, false, cancellationToken).ConfigureAwait(false); + } + + if (anyDlistUploads) + await backendManager.WaitForEmptyAsync(db, null, cancellationToken).ConfigureAwait(false); + + foreach (var volumename in missingLocalFilesets) + { + var remoteVolume = db.GetRemoteVolume(volumename); + using (var tmpfile = await backendManager.GetAsync(remoteVolume.Name, remoteVolume.Hash, remoteVolume.Size, cancellationToken).ConfigureAwait(false)) + { + var parsed = VolumeBase.ParseFilename(remoteVolume.Name); + using (var stream = new FileStream(tmpfile, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var compressor = DynamicLoader.CompressionLoader.GetModule(parsed.CompressionModule, stream, ArchiveMode.Read, m_options.RawOptions)) + using (var transaction = db.BeginTransaction()) + using (var recreatedb = new LocalRecreateDatabase(db, m_options)) + { + if (compressor == null) + throw new UserInformationException(string.Format("Failed to load compression module: {0}", parsed.CompressionModule), "FailedToLoadCompressionModule"); + + var filesetid = db.CreateFileset(remoteVolume.ID, parsed.Time, transaction); + RecreateDatabaseHandler.RecreateFilesetFromRemoteList(recreatedb, transaction, compressor, filesetid, m_options, new FilterExpression()); + transaction.Commit(); + } + } + } + + if (!m_options.Dryrun && tp.MissingVolumes.Any()) + db.TerminatedWithActiveUploads = true; + foreach (var n in tp.MissingVolumes) { IDisposable newEntry = null; @@ -417,7 +500,7 @@ namespace Duplicati.Library.Main.Operation } //Then we grab all remote volumes that have the missing blocks - await foreach (var (tmpfile, _, _, name) in backendManager.GetFilesOverlappedAsync(mbl.GetMissingBlockSources().ToList(), cancellationToken)) + await foreach (var (tmpfile, _, _, name) in backendManager.GetFilesOverlappedAsync(mbl.GetMissingBlockSources().ToList(), cancellationToken).ConfigureAwait(false)) { try { @@ -489,13 +572,15 @@ namespace Duplicati.Library.Main.Operation m_result.OperationProgressUpdater.UpdateProgress(1); await backendManager.WaitForEmptyAsync(db, null, cancellationToken).ConfigureAwait(false); + if (!m_options.Dryrun) + db.TerminatedWithActiveUploads = false; db.WriteResults(); } } public void RunRepairCommon() { - if (!System.IO.File.Exists(m_options.Dbpath)) + if (!File.Exists(m_options.Dbpath)) throw new UserInformationException(string.Format("Database file does not exist: {0}", m_options.Dbpath), "DatabaseDoesNotExist"); m_result.OperationProgressUpdater.UpdateProgress(0); diff --git a/Duplicati/Library/Main/Operation/Restore/BlockManager.cs b/Duplicati/Library/Main/Operation/Restore/BlockManager.cs index de1e8178f..af8a34d0a 100644 --- a/Duplicati/Library/Main/Operation/Restore/BlockManager.cs +++ b/Duplicati/Library/Main/Operation/Restore/BlockManager.cs @@ -1,25 +1,26 @@ // Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the +// +// 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 +// +// 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 +// +// 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.Buffers; using System.Collections.Concurrent; using System.Collections.Generic; using System.Data; @@ -89,6 +90,19 @@ namespace Duplicati.Library.Main.Operation.Restore /// Internal stopwatch for profiling setting up the waiters. /// private readonly Stopwatch sw_get_wait; + /// + /// Internal stopwatch for profiling setting a block in the cache. + /// + private readonly Stopwatch sw_set_set; + /// + /// Internal stopwatch for profiling getting a waiter to notify that the block is available. + /// + private readonly Stopwatch sw_set_wake_get; + /// + /// Internal stopwatch for profiling for waking up the waiting request. + /// + private readonly Stopwatch sw_set_wake_set; + /// /// Dictionary for keeping track of how many times each block is requested. Used to determine when a block is no longer needed. /// @@ -105,6 +119,14 @@ namespace Duplicati.Library.Main.Operation.Restore /// The options for the restore. /// private readonly Options m_options; + /// + /// The cache eviction options. Used for registering a callback when a block is evicted from the cache. + /// + private readonly MemoryCacheEntryOptions m_entry_options = new(); + /// + /// Dictionary to keep track of how many active readers are accessing each block. On eviction, the byte[] buffer can only be returned to the ArrayPool if there are no active readers. + /// + private readonly ConcurrentDictionary m_active_readers = []; /// /// Initializes a new instance of the class. @@ -116,15 +138,39 @@ namespace Duplicati.Library.Main.Operation.Restore { m_options = options; m_volume_request = volume_request; - if (m_options.RestoreCacheMax > 0) + var cache_options = new MemoryCacheOptions(); + m_block_cache = new MemoryCache(cache_options); + m_entry_options.RegisterPostEvictionCallback(async (key, value, reason, state) => { - var cache_options = new MemoryCacheOptions(); - m_block_cache = new MemoryCache(cache_options); - } + bool was_present = false; + while (true) + { + lock (m_blockcount_lock) + { + if (m_active_readers.TryGetValue((long)key, out var ac)) + { + if (ac == 0) + { + was_present = true; + break; + } + } + else + break; + } + + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + } + if (was_present) + ArrayPool.Shared.Return((byte[])value); + }); this.readers = readers; sw_cacheevict = options.InternalProfiling ? new() : null; sw_checkcounts = options.InternalProfiling ? new() : null; sw_get_wait = options.InternalProfiling ? new() : null; + sw_set_set = options.InternalProfiling ? new() : null; + sw_set_wake_get = options.InternalProfiling ? new() : null; + sw_set_wake_set = options.InternalProfiling ? new() : null; foreach (var (block_id, volume_id) in db.GetBlocksAndVolumeIDs(options.SkipMetadata)) { @@ -142,17 +188,23 @@ namespace Duplicati.Library.Main.Operation.Restore /// the VolumeDownloader will be notified. /// /// The block request to check. - /// - public void CheckCounts(BlockRequest blockRequest) + public async Task CheckCounts(BlockRequest blockRequest) { long error_block_id = -1; long error_volume_id = -1; var emit_evict = false; + byte[] data = null; lock (m_blockcount_lock) { sw_checkcounts?.Start(); + var active = m_active_readers.TryGetValue(blockRequest.BlockID, out var ac) ? ac - 1 : 0; + if (active == 0) + m_active_readers.Remove(blockRequest.BlockID, out var _); + else + m_active_readers[blockRequest.BlockID] = active; + var block_count = m_blockcount.TryGetValue(blockRequest.BlockID, out var c) ? c - 1 : 0; if (block_count > 0) @@ -163,7 +215,8 @@ namespace Duplicati.Library.Main.Operation.Restore { // Evict the block from the cache and check if the volume is no longer needed. m_blockcount.Remove(blockRequest.BlockID); - m_block_cache?.Remove(blockRequest.BlockID); + data = m_block_cache.Get(blockRequest.BlockID); + m_block_cache.Remove(blockRequest.BlockID); } else // block_count < 0 { @@ -177,7 +230,6 @@ namespace Duplicati.Library.Main.Operation.Restore } else if (vol_count == 0) { - // Notify the `VolumeManager` that it should evict the volume. m_volumecount.Remove(blockRequest.VolumeID); blockRequest.CacheDecrEvict = true; emit_evict = true; @@ -189,8 +241,12 @@ namespace Duplicati.Library.Main.Operation.Restore sw_checkcounts?.Stop(); } + if (data != null) + ArrayPool.Shared.Return(data); + + // Notify the `VolumeManager` that it should evict the volume. if (emit_evict) - m_volume_request.Write(blockRequest); + await m_volume_request.WriteAsync(blockRequest).ConfigureAwait(false); if (error_block_id != -1) { @@ -210,32 +266,44 @@ namespace Duplicati.Library.Main.Operation.Restore /// /// The requested block. /// A `Task` holding the data block. - public Task Get(BlockRequest block_request) + public async Task Get(BlockRequest block_request) { - // Check if the block is already in the cache, and return it if it is. - if (m_block_cache != null && m_block_cache.TryGetValue(block_request.BlockID, out byte[] value)) + Logging.Log.WriteExplicitMessage(LOGTAG, "BlockCacheGet", "Getting block {0} from cache", block_request.BlockID); + + lock (m_blockcount_lock) { - CheckCounts(block_request); - return Task.FromResult(value); + m_active_readers[block_request.BlockID] = m_active_readers.TryGetValue(block_request.BlockID, out var c) ? c + 1 : 1; } + // Check if the block is already in the cache, and return it if it is. + if (m_block_cache.TryGetValue(block_request.BlockID, out byte[] value)) + return value; + // If the block is not in the cache, request it from the volume. sw_get_wait?.Start(); - var tcs = new TaskCompletionSource(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var new_tcs = m_waiters.GetOrAdd(block_request.BlockID, tcs); if (tcs == new_tcs) { - // We are the first to request this block - m_volume_request.Write(block_request); - } - sw_get_wait?.Stop(); + Logging.Log.WriteExplicitMessage(LOGTAG, "BlockCacheGet", "Requesting block {0} from volume {1}", block_request.BlockID, block_request.VolumeID); - return new_tcs.Task.ContinueWith(t => + // We are the first to request this block + await m_volume_request.WriteAsync(block_request).ConfigureAwait(false); + + sw_get_wait?.Stop(); + + // Add a timeout monitor + using var tcs1 = new CancellationTokenSource(); + var t = await Task.WhenAny(Task.Delay(TimeSpan.FromMinutes(5), tcs1.Token), new_tcs.Task).ConfigureAwait(false); + if (t != new_tcs.Task) + Logging.Log.WriteWarningMessage(LOGTAG, "BlockRequestTimeout", null, "Block request for block {0} has been in flight for over 5 minutes. This may be a deadlock.", block_request.BlockID); + } + else { - if (t.IsCompletedSuccessfully) - CheckCounts(block_request); - return t.Result; - }); + sw_get_wait?.Stop(); + } + + return await new_tcs.Task; } /// @@ -246,22 +314,31 @@ namespace Duplicati.Library.Main.Operation.Restore /// removed, the requester will be given a `Task` that will be /// completed when the block is available. /// - /// - /// + /// The block request related to the value. + /// The byte[] buffer holding the block data. public void Set(BlockRequest blockRequest, byte[] value) { - m_block_cache?.Set(blockRequest.BlockID, value); + Logging.Log.WriteExplicitMessage(LOGTAG, "BlockCacheSet", "Setting block {0} in cache", blockRequest.BlockID); + + sw_set_set?.Start(); + m_block_cache.Set(blockRequest.BlockID, value); + sw_set_set?.Stop(); // Notify any waiters that the block is available. + sw_set_wake_get?.Start(); if (m_waiters.TryRemove(blockRequest.BlockID, out var tcs)) { + sw_set_wake_get?.Stop(); + sw_set_wake_set?.Start(); tcs.SetResult(value); + sw_set_wake_set?.Stop(); } + sw_set_wake_get?.Stop(); sw_cacheevict?.Start(); - if (m_block_cache?.Count * m_options.Blocksize > m_options.RestoreCacheMax) + if (m_block_cache.Count > m_options.RestoreCacheMax) { - m_block_cache?.Compact(m_options.RestoreCacheEvict); + m_block_cache.Compact(m_options.RestoreCacheMax == 0 ? 1.0 : m_options.RestoreCacheEvict); } sw_cacheevict?.Stop(); } @@ -294,7 +371,12 @@ namespace Duplicati.Library.Main.Operation.Restore { var blocks = m_blockcount.Where(x => x.Value != 0).Select(x => x.Key).ToArray(); var blockids = string.Join(", ", blocks); - Logging.Log.WriteWarningMessage(LOGTAG, "BlockCountError", null, $"Block count in SleepableDictionarys block table is not zero: {blockcount}{Environment.NewLine}");//Blocks: {blockids}"); + Logging.Log.WriteWarningMessage(LOGTAG, "BlockCountError", null, $"Block count in SleepableDictionarys block table is not zero: {blockcount}{Environment.NewLine}"); + } + + if (m_active_readers.Count > 0) + { + Logging.Log.WriteWarningMessage(LOGTAG, "BlockCountError", null, $"There are still {m_active_readers.Count} files being read by {m_active_readers.Sum(x => x.Value)} readers"); } if (volumecount != 0) @@ -306,7 +388,13 @@ namespace Duplicati.Library.Main.Operation.Restore if (m_options.InternalProfiling) { - Logging.Log.WriteProfilingMessage(LOGTAG, "InternalTimings", null, $"Sleepable dictionary - CheckCounts: {sw_checkcounts.ElapsedMilliseconds}ms, Get wait: {sw_get_wait.ElapsedMilliseconds}ms, Cache evict: {sw_cacheevict.ElapsedMilliseconds}ms"); + Logging.Log.WriteProfilingMessage(LOGTAG, "InternalTimings", $"Sleepable dictionary - CheckCounts: {sw_checkcounts.ElapsedMilliseconds}ms, Get wait: {sw_get_wait.ElapsedMilliseconds}ms, Set set: {sw_set_set.ElapsedMilliseconds}ms, Set wake get: {sw_set_wake_get.ElapsedMilliseconds}ms, Set wake set: {sw_set_wake_set.ElapsedMilliseconds}ms, Cache evict: {sw_cacheevict.ElapsedMilliseconds}ms"); + } + + if (m_block_cache.Count > 0) + { + Logging.Log.WriteWarningMessage(LOGTAG, "BlockCacheMismatch", null, $"Internal Block cache is not empty: {m_block_cache.Count}"); + Logging.Log.WriteWarningMessage(LOGTAG, "BlockCacheMismatch", null, $"Block counts in cache ({m_blockcount.Count}): {string.Join(", ", m_blockcount.Select(x => x.Value))}"); } } @@ -331,11 +419,12 @@ namespace Duplicati.Library.Main.Operation.Restore /// accessing the cache for the blocks, and writing the resulting /// blocks back to the `FileProcessor`. /// + /// The named channels for the restore operation. /// The database holding information about how many of each block this restore requires. /// The restore options. /// The channels for reading block requests from the `FileProcessor`. /// The channels for writing block responses back to the `FileProcessor`. - public static Task Run(Channels channels, LocalRestoreDatabase db, Options options, IChannel[] fp_requests, IChannel[] fp_responses) + public static Task Run(Channels channels, LocalRestoreDatabase db, Options options, IChannel[] fp_requests, IChannel>[] fp_responses) { return AutomationExtensions.RunTask( new @@ -372,7 +461,7 @@ namespace Duplicati.Library.Main.Operation.Restore if (options.InternalProfiling) { - Logging.Log.WriteProfilingMessage(LOGTAG, "InternalTimings", null, $"Volume consumer - Read: {sw_read.ElapsedMilliseconds}ms, Set: {sw_set.ElapsedMilliseconds}ms"); + Logging.Log.WriteProfilingMessage(LOGTAG, "InternalTimings", $"Volume consumer - Read: {sw_read.ElapsedMilliseconds}ms, Set: {sw_set.ElapsedMilliseconds}ms"); } // Cancel any remaining readers - although there shouldn't be any. @@ -380,10 +469,12 @@ namespace Duplicati.Library.Main.Operation.Restore } catch (Exception ex) { - Logging.Log.WriteWarningMessage(LOGTAG, "VolumeConsumerError", ex, "Error in volume consumer"); + Logging.Log.WriteErrorMessage(LOGTAG, "VolumeConsumerError", ex, "Error in volume consumer"); + self.Input.Retire(); // Cancel any remaining readers - although there shouldn't be any. cache.CancelAll(); + cache.Retire(); } }); @@ -405,17 +496,17 @@ namespace Duplicati.Library.Main.Operation.Restore { sw_cache?.Start(); // Target file already had the block. - cache.CheckCounts(block_request); + await cache.CheckCounts(block_request).ConfigureAwait(false); sw_cache?.Stop(); } else { sw_get?.Start(); - var data = await cache.Get(block_request).ConfigureAwait(false); + var datatask = cache.Get(block_request); sw_get?.Stop(); sw_resp?.Start(); - await res.WriteAsync(data).ConfigureAwait(false); + await res.WriteAsync(datatask).ConfigureAwait(false); sw_resp?.Stop(); } } @@ -426,11 +517,18 @@ namespace Duplicati.Library.Main.Operation.Restore if (options.InternalProfiling) { - Logging.Log.WriteProfilingMessage(LOGTAG, "InternalTimings", null, $"Block handler - Req: {sw_req.ElapsedMilliseconds}ms, Resp: {sw_resp.ElapsedMilliseconds}ms, Cache: {sw_cache.ElapsedMilliseconds}ms, Get: {sw_get.ElapsedMilliseconds}ms"); + Logging.Log.WriteProfilingMessage(LOGTAG, "InternalTimings", $"Block handler - Req: {sw_req.ElapsedMilliseconds}ms, Resp: {sw_resp.ElapsedMilliseconds}ms, Cache: {sw_cache.ElapsedMilliseconds}ms, Get: {sw_get.ElapsedMilliseconds}ms"); } cache.Retire(); } + catch (Exception ex) + { + Logging.Log.WriteErrorMessage(LOGTAG, "BlockHandlerError", ex, "Error in block handler"); + req.Retire(); + res.Retire(); + cache.Retire(); + } })).ToArray(); await Task.WhenAll([volume_consumer, .. block_handlers]).ConfigureAwait(false); diff --git a/Duplicati/Library/Main/Operation/Restore/Channels.cs b/Duplicati/Library/Main/Operation/Restore/Channels.cs index de1f18aab..9ed844e59 100644 --- a/Duplicati/Library/Main/Operation/Restore/Channels.cs +++ b/Duplicati/Library/Main/Operation/Restore/Channels.cs @@ -1,25 +1,24 @@ // Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the +// +// 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 +// +// 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 +// +// 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 CoCoL; using Duplicati.Library.Main.Volumes; using Duplicati.Library.Utility; @@ -36,7 +35,7 @@ namespace Duplicati.Library.Main.Operation.Restore /// The buffer size for the channels. The buffer size is the number of /// messages that can be queued up before the sender blocks. /// - public static readonly int BufferSize = 8; + public static int BufferSize; /// /// Channel between and . @@ -46,12 +45,12 @@ namespace Duplicati.Library.Main.Operation.Restore /// /// Channel between and . /// - public readonly IChannel<(long, Task)> DownloadRequest = ChannelManager.CreateChannel<(long, Task)>(buffersize: BufferSize); + public readonly IChannel DownloadRequest = ChannelManager.CreateChannel(buffersize: BufferSize); /// /// Channel between and /// - public readonly IChannel<(long, TempFile)> DecryptRequest = ChannelManager.CreateChannel<(long, TempFile)>(buffersize: BufferSize); + public readonly IChannel<(long, string, TempFile)> DecryptRequest = ChannelManager.CreateChannel<(long, string, TempFile)>(buffersize: BufferSize); /// /// Channel between and diff --git a/Duplicati/Library/Main/Operation/Restore/FileLister.cs b/Duplicati/Library/Main/Operation/Restore/FileLister.cs index e12b2f0b2..6da3be7e6 100644 --- a/Duplicati/Library/Main/Operation/Restore/FileLister.cs +++ b/Duplicati/Library/Main/Operation/Restore/FileLister.cs @@ -1,22 +1,22 @@ // Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the +// +// 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 +// +// 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 +// +// 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; @@ -43,6 +43,7 @@ namespace Duplicati.Library.Main.Operation.Restore /// Runs the file lister process that lists the files that need to be restored /// and sends them to the . /// + /// The named channels for the restore operation. /// The restore database, which is queried for the file list. /// The restore options /// The restore results @@ -55,37 +56,39 @@ namespace Duplicati.Library.Main.Operation.Restore }, async self => { - Stopwatch sw_prework = options.InternalProfiling ? new () : null; - Stopwatch sw_write = options.InternalProfiling ? new () : null; + Stopwatch sw_prework = options.InternalProfiling ? new() : null; + Stopwatch sw_write = options.InternalProfiling ? new() : null; + + bool threw_exception = false; + try { sw_prework?.Start(); - var files = db.GetFilesAndSymlinksToRestore(true).OrderByDescending(x => x.Length).ToArray(); + var files = db.GetFilesAndSymlinksToRestore(true).OrderByDescending(x => x.Length).ToArray(); // Get started on big files first result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_DownloadingRemoteFiles); sw_prework?.Stop(); sw_write?.Start(); foreach (var file in files) - { - await self.Output.WriteAsync(file); - } + await self.Output.WriteAsync(file).ConfigureAwait(false); sw_write?.Stop(); } - catch (RetiredException) - { - Logging.Log.WriteVerboseMessage(LOGTAG, "RetiredProcess", null, "File lister retired"); - - if (options.InternalProfiling) - { - Logging.Log.WriteProfilingMessage(LOGTAG, "InternalTimings", $"Prework: {sw_prework.ElapsedMilliseconds}ms, Write: {sw_write.ElapsedMilliseconds}ms"); - Console.WriteLine($"File lister - Prework: {sw_prework.ElapsedMilliseconds}ms, Write: {sw_write.ElapsedMilliseconds}ms"); - } - } catch (Exception ex) { Logging.Log.WriteErrorMessage(LOGTAG, "FileListerError", ex, "Error during file listing"); + threw_exception = true; throw; } + finally + { + if (!threw_exception) + Logging.Log.WriteVerboseMessage(LOGTAG, "RetiredProcess", null, "File lister retired"); + + if (options.InternalProfiling) + { + Logging.Log.WriteProfilingMessage(LOGTAG, "InternalTimings", $"Prework: {sw_prework.ElapsedMilliseconds}ms, Write: {sw_write.ElapsedMilliseconds}ms"); + } + } }); } } diff --git a/Duplicati/Library/Main/Operation/Restore/FileProcessor.cs b/Duplicati/Library/Main/Operation/Restore/FileProcessor.cs index 8b61eea9c..7ef1b1fe0 100644 --- a/Duplicati/Library/Main/Operation/Restore/FileProcessor.cs +++ b/Duplicati/Library/Main/Operation/Restore/FileProcessor.cs @@ -1,22 +1,22 @@ // Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the +// +// 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 +// +// 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 +// +// 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; @@ -52,12 +52,13 @@ namespace Duplicati.Library.Main.Operation.Restore /// /// Runs the file processor process that restores the files that need to be restored. /// + /// The named channels for the restore operation. /// The restore database, which is queried for blocks, corresponding volumes and metadata for the files. /// The channel to request blocks from the block manager. /// The channel to receive blocks from the block manager. /// The restore options. /// The restore results. - public static Task Run(Channels channels, LocalRestoreDatabase db, IChannel block_request, IChannel block_response, Options options, RestoreResults results) + public static Task Run(Channels channels, LocalRestoreDatabase db, IChannel block_request, IChannel> block_response, Options options, RestoreResults results) { return AutomationExtensions.RunTask( new @@ -66,23 +67,34 @@ namespace Duplicati.Library.Main.Operation.Restore }, async self => { - Stopwatch sw_file = options.InternalProfiling ? new () : null; - Stopwatch sw_block = options.InternalProfiling ? new () : null; - Stopwatch sw_meta = options.InternalProfiling ? new () : null; - Stopwatch sw_req = options.InternalProfiling ? new () : null; - Stopwatch sw_resp = options.InternalProfiling ? new () : null; - Stopwatch sw_work = options.InternalProfiling ? new () : null; + Stopwatch sw_file = options.InternalProfiling ? new() : null; + Stopwatch sw_block = options.InternalProfiling ? new() : null; + Stopwatch sw_meta = options.InternalProfiling ? new() : null; + Stopwatch sw_req = options.InternalProfiling ? new() : null; + Stopwatch sw_resp = options.InternalProfiling ? new() : null; + Stopwatch sw_work = options.InternalProfiling ? new() : null; + Stopwatch sw_work_verify_target = options.InternalProfiling ? new() : null; + Stopwatch sw_work_retarget = options.InternalProfiling ? new() : null; + Stopwatch sw_work_notify_local = options.InternalProfiling ? new() : null; + Stopwatch sw_work_verify_local = options.InternalProfiling ? new() : null; + Stopwatch sw_work_empty_file = options.InternalProfiling ? new() : null; + Stopwatch sw_work_hash = options.InternalProfiling ? new() : null; + Stopwatch sw_work_read = options.InternalProfiling ? new() : null; + Stopwatch sw_work_write = options.InternalProfiling ? new() : null; + Stopwatch sw_work_results = options.InternalProfiling ? new() : null; + Stopwatch sw_work_meta = options.InternalProfiling ? new() : null; try { using var filehasher = HashFactory.CreateHasher(options.FileHashAlgorithm); using var blockhasher = HashFactory.CreateHasher(options.BlockHashAlgorithm); + var buffer = new byte[options.Blocksize]; while (true) { // Get the next file to restore. sw_file?.Start(); - var file = await self.Input.ReadAsync(); + var file = await self.Input.ReadAsync().ConfigureAwait(false); sw_file?.Stop(); // Get information about the blocks for the file @@ -91,17 +103,18 @@ namespace Duplicati.Library.Main.Operation.Restore var blocks = db.GetBlocksFromFile(file.BlocksetID).ToArray(); sw_block?.Stop(); - sw_work?.Start(); + sw_work_verify_target?.Start(); // Verify the target file blocks that may already exist. - var (bytes_verified, missing_blocks, verified_blocks) = await VerifyTargetBlocks(file, blocks, filehasher, blockhasher, options, results, block_request); + var (bytes_verified, missing_blocks, verified_blocks) = await VerifyTargetBlocks(file, blocks, filehasher, blockhasher, buffer, options, results).ConfigureAwait(false); long bytes_written = 0; + sw_work_verify_target?.Stop(); if (blocks.Length != missing_blocks.Count + verified_blocks.Count) { Logging.Log.WriteErrorMessage(LOGTAG, "BlockCountMismatch", null, $"Block count mismatch for {file.TargetPath} - expected: {blocks.Length}, actual: {missing_blocks.Count + verified_blocks.Count}"); - sw_work?.Stop(); continue; } + sw_work_retarget?.Start(); // Check if the target file needs to be retargeted if (missing_blocks.Count > 0 && !options.Overwrite && SystemIO.IO_OS.FileExists(file.TargetPath)) { @@ -124,22 +137,50 @@ namespace Duplicati.Library.Main.Operation.Restore } else { - CopyOldTargetBlocksToNewTarget(file, new_file, verified_blocks); + await CopyOldTargetBlocksToNewTarget(file, new_file, buffer, verified_blocks).ConfigureAwait(false); } } + else + { + verified_blocks.Clear(); + missing_blocks = [.. blocks.Select(x => { x.CacheDecrEvict = false; return x; })]; + } file = new_file; } } + sw_work_retarget?.Stop(); + sw_work_notify_local?.Start(); + // Notify to the cache that we use local blocks. + foreach (var block in verified_blocks) + { + sw_req?.Start(); + await block_request.WriteAsync( + new BlockRequest( + block.BlockID, + block.BlockOffset, + block.BlockHash, + block.BlockSize, + block.VolumeID, + true + ) + ).ConfigureAwait(false); + sw_req?.Stop(); + } + sw_work_notify_local?.Stop(); + + sw_work_verify_local?.Start(); if (missing_blocks.Count > 0 && options.UseLocalBlocks) { // Verify the local blocks at the original restore path that may be used to restore the file. - (bytes_written, missing_blocks) = await VerifyLocalBlocks(file, missing_blocks, blocks.Length, filehasher, blockhasher, options, results, block_request); + (bytes_written, missing_blocks) = await VerifyLocalBlocks(file, missing_blocks, blocks.Length, filehasher, blockhasher, buffer, options, results, block_request).ConfigureAwait(false); } + sw_work_verify_local?.Stop(); bool empty_file_or_symlink = false; if (file.BlocksetID != LocalDatabase.SYMLINK_BLOCKSET_ID && (blocks.Length == 0 || (blocks.Length == 1 && blocks[0].BlockSize == 0))) { + sw_work_empty_file?.Start(); empty_file_or_symlink = true; if (options.Dryrun) { @@ -159,10 +200,19 @@ namespace Duplicati.Library.Main.Operation.Restore fs.SetLength(0); if (missing_blocks.Count != 0) { - blocks[0].CacheDecrEvict = true; - await block_request.WriteAsync(blocks[0]); + await block_request.WriteAsync( + new BlockRequest( + blocks[0].BlockID, + blocks[0].BlockOffset, + blocks[0].BlockHash, + blocks[0].BlockSize, + blocks[0].VolumeID, + true + ) + ).ConfigureAwait(false); } } + sw_work_empty_file?.Stop(); } else if (missing_blocks.Count > 0) { @@ -172,7 +222,9 @@ namespace Duplicati.Library.Main.Operation.Restore continue; } + sw_work_hash?.Start(); filehasher.Initialize(); + sw_work_hash?.Stop(); FileStream fs = null; try @@ -201,14 +253,22 @@ namespace Duplicati.Library.Main.Operation.Restore fs = SystemIO.IO_OS.FileOpenReadWrite(file.TargetPath); } - sw_work?.Stop(); sw_req?.Start(); // Burst the block requests to speed up the restore int burst = Channels.BufferSize; int j = 0; - for (int i = 0; i < (int) Math.Min(missing_blocks.Count, burst); i++) + for (int i = 0; i < (int)Math.Min(missing_blocks.Count, burst); i++) { - await block_request.WriteAsync(missing_blocks[i]); + await block_request.WriteAsync( + new BlockRequest( + missing_blocks[i].BlockID, + missing_blocks[i].BlockOffset, + missing_blocks[i].BlockHash, + missing_blocks[i].BlockSize, + missing_blocks[i].VolumeID, + false + ) + ).ConfigureAwait(false); } sw_req?.Stop(); @@ -218,27 +278,36 @@ namespace Duplicati.Library.Main.Operation.Restore fs.SetLength(file.Length); } - sw_work?.Start(); for (int i = 0; i < blocks.Length; i++) { // Each response block is not verified against the block hash, as the processes downloading the blocks should do that. if (j < missing_blocks.Count && missing_blocks[j].BlockOffset == i) { // Read the block from the response and issue a new request, if more blocks are missing - sw_work?.Stop(); sw_resp?.Start(); - var data = await block_response.ReadAsync(); + var data = await (await block_response.ReadAsync().ConfigureAwait(false)).ConfigureAwait(false); sw_resp?.Stop(); + sw_req?.Start(); if (j < missing_blocks.Count - burst) { - await block_request.WriteAsync(missing_blocks[j + burst]); + await block_request.WriteAsync( + new BlockRequest( + missing_blocks[j + burst].BlockID, + missing_blocks[j + burst].BlockOffset, + missing_blocks[j + burst].BlockHash, + missing_blocks[j + burst].BlockSize, + missing_blocks[j + burst].VolumeID, + false + ) + ).ConfigureAwait(false); } sw_req?.Stop(); - sw_work?.Start(); // Hash the block to verify the file hash - filehasher.TransformBlock(data, 0, data.Length, data, 0); + sw_work_hash?.Start(); + filehasher.TransformBlock(data, 0, (int)blocks[i].BlockSize, data, 0); + sw_work_hash?.Stop(); if (options.Dryrun) { // Simulate writing the block @@ -247,19 +316,36 @@ namespace Duplicati.Library.Main.Operation.Restore else { // Write the block to the file - await fs.WriteAsync(data); + sw_work_write?.Start(); + await fs.WriteAsync(data.AsMemory(0, (int)blocks[i].BlockSize)).ConfigureAwait(false); + sw_work_write?.Stop(); } // Keep track of metrics - bytes_written += data.Length; + bytes_written += blocks[i].BlockSize; j++; + sw_req?.Start(); + await block_request.WriteAsync( + new BlockRequest( + blocks[i].BlockID, + blocks[i].BlockOffset, + blocks[i].BlockHash, + blocks[i].BlockSize, + blocks[i].VolumeID, + true + ) + ).ConfigureAwait(false); + sw_req?.Stop(); } else { // Not a missing block, so read from the file - var data = new byte[blocks[i].BlockSize]; - var read = await fs?.ReadAsync(data, 0, data.Length); - filehasher.TransformBlock(data, 0, read, data, 0); + sw_work_read?.Start(); + var read = await fs?.ReadAsync(buffer, 0, buffer.Length); + sw_work_read?.Stop(); + sw_work_hash?.Start(); + filehasher.TransformBlock(buffer, 0, read, buffer, 0); + sw_work_hash?.Stop(); } } @@ -269,7 +355,11 @@ namespace Duplicati.Library.Main.Operation.Restore } // Verify the file hash + sw_work_hash?.Start(); filehasher.TransformFinalBlock([], 0, 0); + sw_work_hash?.Stop(); + + sw_work?.Start(); if (Convert.ToBase64String(filehasher.Hash) != file.Hash) { Logging.Log.WriteErrorMessage(LOGTAG, "FileHashMismatch", null, $"File hash mismatch for {file.TargetPath} - expected: {file.Hash}, actual: {Convert.ToBase64String(filehasher.Hash)}"); @@ -288,6 +378,7 @@ namespace Duplicati.Library.Main.Operation.Restore fs.SetLength(file.Length); } } + sw_work?.Stop(); } catch (Exception) { @@ -295,6 +386,8 @@ namespace Duplicati.Library.Main.Operation.Restore { results.BrokenLocalFiles.Add(file.TargetPath); } + block_request.Retire(); + block_response.Retire(); throw; } finally @@ -303,11 +396,15 @@ namespace Duplicati.Library.Main.Operation.Restore } } - if (!options.SkipMetadata) { - empty_file_or_symlink |= await RestoreMetadata(db, file, block_request, block_response, options, sw_meta, sw_work, sw_req, sw_resp); + if (!options.SkipMetadata) + { + empty_file_or_symlink |= await RestoreMetadata(db, file, block_request, block_response, options, sw_meta, sw_work_meta, sw_req, sw_resp).ConfigureAwait(false); + sw_work_meta?.Stop(); } // TODO legacy restore doesn't count metadata restore as a restored file. + + sw_work_results?.Start(); if (empty_file_or_symlink || bytes_written > 0) { // Keep track of the restored files and their sizes @@ -317,7 +414,7 @@ namespace Duplicati.Library.Main.Operation.Restore results.SizeOfRestoredFiles += bytes_written; } } - sw_work?.Stop(); + sw_work_results?.Stop(); Logging.Log.WriteVerboseMessage(LOGTAG, "RestoredFile", "Restored file {0}", file.TargetPath); } @@ -325,21 +422,22 @@ namespace Duplicati.Library.Main.Operation.Restore catch (RetiredException) { Logging.Log.WriteVerboseMessage(LOGTAG, "RetiredProcess", null, "File processor retired"); - block_request.Retire(); - block_response.Retire(); if (options.InternalProfiling) { - Logging.Log.WriteProfilingMessage(LOGTAG, "InternalTimings", $"File: {sw_file.ElapsedMilliseconds}ms, Block: {sw_block.ElapsedMilliseconds}ms, Meta: {sw_meta.ElapsedMilliseconds}ms, Req: {sw_req.ElapsedMilliseconds}ms, Resp: {sw_resp.ElapsedMilliseconds}ms, Work: {sw_work.ElapsedMilliseconds}ms"); + Logging.Log.WriteProfilingMessage(LOGTAG, "InternalTimings", $"File: {sw_file.ElapsedMilliseconds}ms, Block: {sw_block.ElapsedMilliseconds}ms, Meta: {sw_meta.ElapsedMilliseconds}ms, Req: {sw_req.ElapsedMilliseconds}ms, Resp: {sw_resp.ElapsedMilliseconds}ms, Work: {sw_work.ElapsedMilliseconds}ms, VerifyTarget: {sw_work_verify_target.ElapsedMilliseconds}ms, Retarget: {sw_work_retarget.ElapsedMilliseconds}ms, NotifyLocal: {sw_work_notify_local.ElapsedMilliseconds}ms, VerifyLocal: {sw_work_verify_local.ElapsedMilliseconds}ms, EmptyFile: {sw_work_empty_file.ElapsedMilliseconds}ms, Hash: {sw_work_hash.ElapsedMilliseconds}ms, Read: {sw_work_read.ElapsedMilliseconds}ms, Write: {sw_work_write.ElapsedMilliseconds}ms, Results: {sw_work_results.ElapsedMilliseconds}ms, Meta: {sw_work_meta.ElapsedMilliseconds}ms"); } return; } catch (Exception ex) { Logging.Log.WriteErrorMessage(LOGTAG, "FileProcessingError", ex, "Error during file processing"); + throw; + } + finally + { block_request.Retire(); block_response.Retire(); - throw; } }); } @@ -350,7 +448,7 @@ namespace Duplicati.Library.Main.Operation.Restore /// The old target file. /// The new target file. /// The blocks in the old file that were verified. - private static void CopyOldTargetBlocksToNewTarget(FileRequest old_file, FileRequest new_file, List verified_blocks) + private static async Task CopyOldTargetBlocksToNewTarget(FileRequest old_file, FileRequest new_file, byte[] buffer, List verified_blocks) { using var fs_old = SystemIO.IO_OS.FileOpenRead(old_file.TargetPath); using var fs_new = SystemIO.IO_OS.FileOpenWrite(new_file.TargetPath); @@ -360,9 +458,8 @@ namespace Duplicati.Library.Main.Operation.Restore fs_old.Seek(block.BlockOffset * block.BlockSize, SeekOrigin.Begin); fs_new.Seek(block.BlockOffset * block.BlockSize, SeekOrigin.Begin); - var buffer = new byte[block.BlockSize]; - fs_old.Read(buffer, 0, buffer.Length); - fs_new.Write(buffer, 0, buffer.Length); + await fs_old.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + await fs_new.WriteAsync(buffer, 0, buffer.Length).ConfigureAwait(false); ; } } @@ -438,13 +535,11 @@ namespace Duplicati.Library.Main.Operation.Restore /// The stopwatch for internal profiling of the general processing. /// The stopwatch for internal profiling of the block requests. /// The stopwatch for internal profiling of the block responses. - private static async Task RestoreMetadata(LocalRestoreDatabase db, FileRequest file, IChannel block_request, IChannel block_response, Options options, Stopwatch sw_meta, Stopwatch sw_work, Stopwatch sw_req, Stopwatch sw_resp) + private static async Task RestoreMetadata(LocalRestoreDatabase db, FileRequest file, IChannel block_request, IChannel> block_response, Options options, Stopwatch sw_meta, Stopwatch sw_work, Stopwatch sw_req, Stopwatch sw_resp) { - sw_work?.Stop(); sw_meta?.Start(); - var blocks = db.GetMetadataBlocksFromFile(file.ID).ToArray(); + var blocks = db.GetMetadataBlocksFromFile(file.ID); sw_meta?.Stop(); - sw_work?.Start(); using var ms = new MemoryStream(); @@ -452,13 +547,40 @@ namespace Duplicati.Library.Main.Operation.Restore { sw_work?.Stop(); sw_req?.Start(); - await block_request.WriteAsync(block); + await block_request.WriteAsync( + new BlockRequest( + block.BlockID, + block.BlockOffset, + block.BlockHash, + block.BlockSize, + block.VolumeID, + false + ) + ).ConfigureAwait(false); sw_req?.Stop(); + sw_resp?.Start(); - var data = await block_response.ReadAsync(); + var data = await (await block_response.ReadAsync().ConfigureAwait(false)).ConfigureAwait(false); sw_resp?.Stop(); + + sw_work?.Start(); + ms.Write(data, 0, (int)block.BlockSize); + sw_work?.Stop(); + + sw_req?.Start(); + await block_request.WriteAsync( + new BlockRequest( + block.BlockID, + block.BlockOffset, + block.BlockHash, + block.BlockSize, + block.VolumeID, + true + ) + ).ConfigureAwait(false); + sw_req?.Stop(); + sw_work?.Start(); - ms.Write(data, 0, data.Length); } ms.Seek(0, SeekOrigin.Begin); @@ -472,11 +594,11 @@ namespace Duplicati.Library.Main.Operation.Restore /// The metadata about the blocks that make up the file. /// A hasher for the file. /// A hasher for a data block. + /// A buffer to read and write data blocks. /// The Duplicati configuration options. /// The restoration results. - /// The channel to request blocks from the block manager. Used to inform the block manager which blocks are already present. /// An awaitable `Task`, which returns a collection of data blocks that are missing. - private static async Task<(long,List,List)> VerifyTargetBlocks(FileRequest file, BlockRequest[] blocks, System.Security.Cryptography.HashAlgorithm filehasher, System.Security.Cryptography.HashAlgorithm blockhasher, Options options, RestoreResults results, IChannel block_request) + private static async Task<(long, List, List)> VerifyTargetBlocks(FileRequest file, BlockRequest[] blocks, System.Security.Cryptography.HashAlgorithm filehasher, System.Security.Cryptography.HashAlgorithm blockhasher, byte[] buffer, Options options, RestoreResults results) { long bytes_read = 0; List missing_blocks = []; @@ -489,10 +611,9 @@ namespace Duplicati.Library.Main.Operation.Restore try { using var f = SystemIO.IO_OS.FileOpenRead(file.TargetPath); - var buffer = new byte[options.Blocksize]; for (int i = 0; i < blocks.Length; i++) { - var read = await f.ReadAsync(buffer, 0, (int)blocks[i].BlockSize); + var read = await f.ReadAsync(buffer, 0, (int)blocks[i].BlockSize).ConfigureAwait(false); if (read == blocks[i].BlockSize) { // Block is present @@ -502,8 +623,6 @@ namespace Duplicati.Library.Main.Operation.Restore { // Block matches bytes_read += read; - blocks[i].CacheDecrEvict = true; - await block_request.WriteAsync(blocks[i]); verified_blocks.Add(blocks[i]); } else @@ -541,7 +660,7 @@ namespace Duplicati.Library.Main.Operation.Restore if (Convert.ToBase64String(filehasher.Hash) == file.Hash) { // Truncate the file if it is larger than the expected size. - FileInfo fi = new (file.TargetPath); + FileInfo fi = new(file.TargetPath); if (file.Length < fi.Length) { if (options.Dryrun) @@ -576,12 +695,19 @@ namespace Duplicati.Library.Main.Operation.Restore /// The total number of blocks for the file. /// A hasher for the file. /// A hasher for the data block. + /// A buffer to read and write data blocks. /// The Duplicati configuration options. /// The restoration results. /// The channel to request blocks from the block manager. Used to inform the block manager which blocks are already present. /// An awaitable `Task`, which returns a collection of data blocks that are missing. - private static async Task<(long, List)> VerifyLocalBlocks(FileRequest file, List blocks, long total_blocks, System.Security.Cryptography.HashAlgorithm filehasher, System.Security.Cryptography.HashAlgorithm blockhasher, Options options, RestoreResults results, IChannel block_request) + private static async Task<(long, List)> VerifyLocalBlocks(FileRequest file, List blocks, long total_blocks, System.Security.Cryptography.HashAlgorithm filehasher, System.Security.Cryptography.HashAlgorithm blockhasher, byte[] buffer, Options options, RestoreResults results, IChannel block_request) { + if (file.TargetPath == file.OriginalPath) + { + // The original file is the same as the target file, so no new blocks can be used. + return (0, blocks); + } + List missing_blocks = []; List verified_blocks = []; @@ -596,8 +722,7 @@ namespace Duplicati.Library.Main.Operation.Restore (SystemIO.IO_OS.FileExists(file.TargetPath) ? SystemIO.IO_OS.FileOpenRead(file.TargetPath) : null) : - SystemIO.IO_OS.FileOpenWrite(file.TargetPath); - var buffer = new byte[options.Blocksize]; + SystemIO.IO_OS.FileOpenReadWrite(file.TargetPath); long bytes_read = 0; long bytes_written = 0; @@ -611,7 +736,7 @@ namespace Duplicati.Library.Main.Operation.Restore try { f_original.Seek(i * options.Blocksize, SeekOrigin.Begin); - read = await f_original.ReadAsync(buffer, 0, (int) blocks[j].BlockSize); + read = await f_original.ReadAsync(buffer, 0, (int)blocks[j].BlockSize).ConfigureAwait(false); } catch (Exception) { @@ -632,25 +757,33 @@ namespace Duplicati.Library.Main.Operation.Restore else { if (!options.Dryrun) - { - try { - f_target.Seek(blocks[j].BlockOffset * options.Blocksize, SeekOrigin.Begin); - await f_target.WriteAsync(buffer, 0, read); - } - catch (Exception) - { - lock (results) + try { - results.BrokenLocalFiles.Add(file.TargetPath); + f_target?.Seek(blocks[j].BlockOffset * options.Blocksize, SeekOrigin.Begin); + await f_target?.WriteAsync(buffer, 0, read); } - throw; + catch (Exception) + { + lock (results) + { + results.BrokenLocalFiles.Add(file.TargetPath); + } + throw; } } bytes_read += read; bytes_written += read; - blocks[j].CacheDecrEvict = true; - await block_request.WriteAsync(blocks[j]); + await block_request.WriteAsync( + new BlockRequest( + blocks[j].BlockID, + blocks[j].BlockOffset, + blocks[j].BlockHash, + blocks[j].BlockSize, + blocks[j].VolumeID, + true + ) + ).ConfigureAwait(false); verified_blocks.Add(blocks[j]); } } diff --git a/Duplicati/Library/Main/Operation/Restore/VolumeDecompressor.cs b/Duplicati/Library/Main/Operation/Restore/VolumeDecompressor.cs index 37bc9ad3f..0d2de024f 100644 --- a/Duplicati/Library/Main/Operation/Restore/VolumeDecompressor.cs +++ b/Duplicati/Library/Main/Operation/Restore/VolumeDecompressor.cs @@ -1,25 +1,26 @@ // Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the +// +// 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 +// +// 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 +// +// 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.Buffers; using System.Diagnostics; using System.Threading.Tasks; using CoCoL; @@ -41,6 +42,7 @@ namespace Duplicati.Library.Main.Operation.Restore /// /// Runs the volume decompressor process. /// + /// The named channels for the restore operation. /// The restore options public static Task Run(Channels channels, Options options) { @@ -52,39 +54,47 @@ namespace Duplicati.Library.Main.Operation.Restore }, async self => { - Stopwatch sw_read = options.InternalProfiling ? new () : null; - Stopwatch sw_write = options.InternalProfiling ? new () : null; - Stopwatch sw_decompress = options.InternalProfiling ? new () : null; - Stopwatch sw_verify = options.InternalProfiling ? new () : null; + Stopwatch sw_read = options.InternalProfiling ? new() : null; + Stopwatch sw_write = options.InternalProfiling ? new() : null; + Stopwatch sw_decompress_alloc = options.InternalProfiling ? new() : null; + Stopwatch sw_decompress_locking = options.InternalProfiling ? new() : null; + Stopwatch sw_decompress_read = options.InternalProfiling ? new() : null; + Stopwatch sw_verify = options.InternalProfiling ? new() : null; - try { + try + { using var block_hasher = HashFactory.CreateHasher(options.BlockHashAlgorithm); while (true) { sw_read?.Start(); // Get the block request and volume from the `VolumeDecryptor` process. - var (block_request, volume_reader) = await self.Input.ReadAsync(); + var (block_request, volume_reader) = await self.Input.ReadAsync().ConfigureAwait(false); sw_read?.Stop(); - sw_decompress?.Start(); - var data = new byte[block_request.BlockSize]; + sw_decompress_alloc?.Start(); + var data = ArrayPool.Shared.Rent(options.Blocksize); + sw_decompress_alloc?.Stop(); + sw_decompress_locking?.Start(); lock (volume_reader) // The BlockVolumeReader is not thread-safe { + sw_decompress_locking?.Stop(); + sw_decompress_read?.Start(); volume_reader.ReadBlock(block_request.BlockHash, data); + sw_decompress_read?.Stop(); } - sw_decompress?.Stop(); sw_verify?.Start(); var hash = Convert.ToBase64String(block_hasher.ComputeHash(data, 0, (int)block_request.BlockSize)); - if (hash != block_request.BlockHash) { + if (hash != block_request.BlockHash) + { Logging.Log.WriteErrorMessage(LOGTAG, "InvalidBlock", null, $"Invalid block detected for block {block_request.BlockID} in volume {block_request.VolumeID}, expected hash: {block_request.BlockHash}, actual hash: {hash}"); } sw_verify?.Stop(); sw_write?.Start(); // Send the block to the `BlockManager` process. - await self.Output.WriteAsync((block_request, data)); + await self.Output.WriteAsync((block_request, data)).ConfigureAwait(false); sw_write?.Stop(); } } @@ -94,12 +104,14 @@ namespace Duplicati.Library.Main.Operation.Restore if (options.InternalProfiling) { - Logging.Log.WriteProfilingMessage(LOGTAG, "InternalTimings", $"Read: {sw_read.ElapsedMilliseconds}ms, Write: {sw_write.ElapsedMilliseconds}ms, Decompress: {sw_decompress.ElapsedMilliseconds}ms, Verify: {sw_verify.ElapsedMilliseconds}ms"); + Logging.Log.WriteProfilingMessage(LOGTAG, "InternalTimings", $"Read: {sw_read.ElapsedMilliseconds}ms, Write: {sw_write.ElapsedMilliseconds}ms, Decompress allocate: {sw_decompress_alloc.ElapsedMilliseconds}ms, Decompress lock: {sw_decompress_locking.ElapsedMilliseconds}ms, Decompress read: {sw_decompress_read.ElapsedMilliseconds}ms, Verify: {sw_verify.ElapsedMilliseconds}ms"); } } catch (Exception ex) { Logging.Log.WriteErrorMessage(LOGTAG, "DecompressionError", ex, "Error during decompression"); + self.Input.Retire(); + self.Output.Retire(); throw; } }); diff --git a/Duplicati/Library/Main/Operation/Restore/VolumeDecryptor.cs b/Duplicati/Library/Main/Operation/Restore/VolumeDecryptor.cs index 749a8e67b..0d769c256 100644 --- a/Duplicati/Library/Main/Operation/Restore/VolumeDecryptor.cs +++ b/Duplicati/Library/Main/Operation/Restore/VolumeDecryptor.cs @@ -1,26 +1,27 @@ // Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the +// +// 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 +// +// 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 +// +// 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.Diagnostics; +using System.Linq; using System.Threading.Tasks; using CoCoL; using Duplicati.Library.Main.Volumes; @@ -41,8 +42,10 @@ namespace Duplicati.Library.Main.Operation.Restore /// /// Runs the volume decryptor process. /// + /// The named channels for the restore operation. + /// The backend manager. /// The restore options. - public static Task Run(Channels channels, Options options) + public static Task Run(Channels channels, IBackendManager backend, Options options) { return AutomationExtensions.RunTask( new @@ -52,25 +55,26 @@ namespace Duplicati.Library.Main.Operation.Restore }, async self => { - Stopwatch sw_read = options.InternalProfiling ? new () : null; - Stopwatch sw_write = options.InternalProfiling ? new () : null; - Stopwatch sw_decrypt = options.InternalProfiling ? new () : null; + Stopwatch sw_read = options.InternalProfiling ? new() : null; + Stopwatch sw_write = options.InternalProfiling ? new() : null; + Stopwatch sw_decrypt = options.InternalProfiling ? new() : null; try { while (true) { // Get the block request and volume from the `VolumeDownloader` process. sw_read?.Start(); - var (volume_id, volume) = await self.Input.ReadAsync(); + var (volume_id, volume_name, volume) = await self.Input.ReadAsync().ConfigureAwait(false); sw_read?.Stop(); sw_decrypt?.Start(); - var bvr = new BlockVolumeReader(options.CompressionModule, volume, options); + var tmpfile = backend.DecryptFile(volume, volume_name, options); + var bvr = new BlockVolumeReader(options.CompressionModule, tmpfile, options); sw_decrypt?.Stop(); sw_write?.Start(); // Pass the decrypted volume to the `VolumeDecompressor` process. - await self.Output.WriteAsync((volume_id, volume, bvr)); + await self.Output.WriteAsync((volume_id, tmpfile, bvr)).ConfigureAwait(false); sw_write?.Stop(); } } @@ -86,6 +90,8 @@ namespace Duplicati.Library.Main.Operation.Restore catch (Exception ex) { Logging.Log.WriteErrorMessage(LOGTAG, "DecryptionError", ex, "Error during decryption"); + self.Input.Retire(); + self.Output.Retire(); throw; } }); diff --git a/Duplicati/Library/Main/Operation/Restore/VolumeDownloader.cs b/Duplicati/Library/Main/Operation/Restore/VolumeDownloader.cs index 7aafb3e38..b615c4d8e 100644 --- a/Duplicati/Library/Main/Operation/Restore/VolumeDownloader.cs +++ b/Duplicati/Library/Main/Operation/Restore/VolumeDownloader.cs @@ -1,27 +1,28 @@ // Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the +// +// 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 +// +// 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 +// +// 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.Diagnostics; using System.Linq; +using System.Threading; using System.Threading.Tasks; using CoCoL; using Duplicati.Library.Main.Database; @@ -43,9 +44,12 @@ namespace Duplicati.Library.Main.Operation.Restore /// /// Runs the volume downloader process. /// + /// The named channels for the restore operation. + /// The local restore database, used to find volume information given the volume ID. + /// The backend to use for downloading the volumes. /// The restore options. /// The restore results. - public static Task Run(Channels channels, LocalRestoreDatabase db, Options options, RestoreResults results) + public static Task Run(Channels channels, LocalRestoreDatabase db, IBackendManager backend, Options options, RestoreResults results) { return AutomationExtensions.RunTask( new @@ -65,19 +69,19 @@ namespace Duplicati.Library.Main.Operation.Restore { // Get the block request from the `BlockManager` process. sw_read?.Start(); - var (volume_id, waittask) = await self.Input.ReadAsync(); + var volume_id = await self.Input.ReadAsync().ConfigureAwait(false); sw_read?.Stop(); // Trigger the download. sw_wait?.Start(); - TempFile f = null; + TempFile f; + var (volume_name, size, hash) = db.GetVolumeInfo(volume_id).First(); try { - f = await waittask.ConfigureAwait(false); + f = await backend.GetDirectAsync(volume_name, hash, size, CancellationToken.None).ConfigureAwait(false); } catch (Exception) { - var (volume_name, _, _) = db.GetVolumeInfo(volume_id).First(); lock (results) results.BrokenRemoteFiles.Add(volume_name); @@ -87,7 +91,7 @@ namespace Duplicati.Library.Main.Operation.Restore // Pass the download handle (which may or may not have downloaded already) to the `VolumeDecryptor` process. sw_write?.Start(); - await self.Output.WriteAsync((volume_id, f)); + await self.Output.WriteAsync((volume_id, volume_name, f)).ConfigureAwait(false); sw_write?.Stop(); } } @@ -103,6 +107,8 @@ namespace Duplicati.Library.Main.Operation.Restore catch (Exception ex) { Logging.Log.WriteErrorMessage(LOGTAG, "DownloadError", ex, "Error during download"); + self.Input.Retire(); + self.Output.Retire(); throw; } }); diff --git a/Duplicati/Library/Main/Operation/Restore/VolumeManager.cs b/Duplicati/Library/Main/Operation/Restore/VolumeManager.cs index 4622ce03d..700de7b88 100644 --- a/Duplicati/Library/Main/Operation/Restore/VolumeManager.cs +++ b/Duplicati/Library/Main/Operation/Restore/VolumeManager.cs @@ -1,26 +1,25 @@ // Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the +// +// 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 +// +// 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 +// +// 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 CoCoL; -using Duplicati.Library.Main.Database; using Duplicati.Library.Main.Volumes; using Duplicati.Library.Utility; using System; @@ -46,11 +45,9 @@ namespace Duplicati.Library.Main.Operation.Restore /// /// Runs the volume manager process. /// - /// The local restore database, used to find the volume where a block is stored. - /// The backend manager, used to fetch the volumes from the backend. + /// The named channels for the restore operation. /// The restore options. - /// The restore results. - public static Task Run(Channels channels, LocalRestoreDatabase db, IBackendManager backend, Options options, RestoreResults results) + public static Task Run(Channels channels, Options options) { return AutomationExtensions.RunTask( new @@ -62,7 +59,7 @@ namespace Duplicati.Library.Main.Operation.Restore async self => { Dictionary cache = []; - Dictionary files = []; + Dictionary tmpfiles = []; Dictionary> in_flight = []; Stopwatch sw_cache_set = options.InternalProfiling ? new() : null; @@ -76,7 +73,7 @@ namespace Duplicati.Library.Main.Operation.Restore { while (true) { - var msg = await self.VolumeRequestResponse.ReadAsync(); + var msg = await self.VolumeRequestResponse.ReadAsync().ConfigureAwait(false); switch (msg) { @@ -87,32 +84,29 @@ namespace Duplicati.Library.Main.Operation.Restore sw_cache_evict?.Start(); cache.Remove(request.VolumeID, out var reader); reader?.Dispose(); - files.Remove(request.VolumeID, out var tempfile); - tempfile?.Dispose(); + tmpfiles.Remove(request.VolumeID, out var tmpfile); + tmpfile?.Dispose(); sw_cache_evict?.Stop(); } else { + Logging.Log.WriteExplicitMessage(LOGTAG, "VolumeRequest", "Requesting block {0} from volume {1}", request.BlockID, request.VolumeID); sw_request?.Start(); if (cache.TryGetValue(request.VolumeID, out BlockVolumeReader reader)) { - await self.DecompressRequest.WriteAsync((request, reader)); + Logging.Log.WriteExplicitMessage(LOGTAG, "VolumeRequest", "Block {0} found in cache", request.BlockID); + await self.DecompressRequest.WriteAsync((request, reader)).ConfigureAwait(false); } else { + Logging.Log.WriteExplicitMessage(LOGTAG, "VolumeRequest", "Block {0} not found in cache, requesting volume {1}", request.BlockID, request.VolumeID); if (in_flight.TryGetValue(request.VolumeID, out var waiters)) { waiters.Add(request); } else { - sw_query?.Start(); - var (volume_name, volume_size, volume_hash) = db.GetVolumeInfo(request.VolumeID).First(); - sw_query?.Stop(); - sw_backend?.Start(); - var handle = backend.GetAsync(volume_name, volume_hash, volume_size, results.TaskControl.TransferToken); - sw_backend?.Stop(); - await self.DownloadRequest.WriteAsync((request.VolumeID, handle)); + await self.DownloadRequest.WriteAsync(request.VolumeID).ConfigureAwait(false); in_flight[request.VolumeID] = [request]; } } @@ -120,16 +114,17 @@ namespace Duplicati.Library.Main.Operation.Restore } break; } - case (long volume_id, TempFile temp_file, BlockVolumeReader reader): + case (long volume_id, TempFile tmpfile, BlockVolumeReader reader): { sw_cache_set?.Start(); cache[volume_id] = reader; - files[volume_id] = temp_file; + tmpfiles[volume_id] = tmpfile; sw_cache_set?.Stop(); sw_wakeup?.Start(); foreach (var request in in_flight[volume_id]) { - await self.DecompressRequest.WriteAsync((request, reader)); + Logging.Log.WriteExplicitMessage(LOGTAG, "VolumeRequest", "Requesting block {0} from volume {1}", request.BlockID, volume_id); + await self.DecompressRequest.WriteAsync((request, reader)).ConfigureAwait(false); } in_flight.Remove(volume_id); sw_wakeup?.Stop(); diff --git a/Duplicati/Library/Main/Operation/RestoreControlFilesHandler.cs b/Duplicati/Library/Main/Operation/RestoreControlFilesHandler.cs index e3739f4dc..8bff6e456 100644 --- a/Duplicati/Library/Main/Operation/RestoreControlFilesHandler.cs +++ b/Duplicati/Library/Main/Operation/RestoreControlFilesHandler.cs @@ -20,7 +20,9 @@ // DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; +using System.IO; using System.Threading; +using System.Threading.Tasks; using Duplicati.Library.Utility; namespace Duplicati.Library.Main.Operation @@ -36,35 +38,34 @@ namespace Duplicati.Library.Main.Operation m_result = result; } - public void Run(IEnumerable filterstrings, IBackendManager backendManager, Library.Utility.IFilter compositefilter) + public async Task RunAsync(IEnumerable filterstrings, IBackendManager backendManager, Library.Utility.IFilter compositefilter) { - var cancellationToken = CancellationToken.None; if (string.IsNullOrEmpty(m_options.Restorepath)) throw new Exception("Cannot restore control files without --restore-path"); - if (!System.IO.Directory.Exists(m_options.Restorepath)) - System.IO.Directory.CreateDirectory(m_options.Restorepath); + if (!Directory.Exists(m_options.Restorepath)) + Directory.CreateDirectory(m_options.Restorepath); - using (var tmpdb = new Library.Utility.TempFile()) - using (var db = new Database.LocalDatabase(System.IO.File.Exists(m_options.Dbpath) ? m_options.Dbpath : (string)tmpdb, "RestoreControlFiles", true)) + using (var tmpdb = new TempFile()) + using (var db = new Database.LocalDatabase(File.Exists(m_options.Dbpath) ? m_options.Dbpath : (string)tmpdb, "RestoreControlFiles", true)) { m_result.SetDatabase(db); - var filter = Library.Utility.JoinedFilterExpression.Join(new Library.Utility.FilterExpression(filterstrings), compositefilter); + var filter = JoinedFilterExpression.Join(new FilterExpression(filterstrings), compositefilter); try { - var filteredList = ListFilesHandler.ParseAndFilterFilesets(backendManager.ListAsync(cancellationToken).Await(), m_options); + var filteredList = ListFilesHandler.ParseAndFilterFilesets(await backendManager.ListAsync(m_result.TaskControl.ProgressToken).ConfigureAwait(false), m_options); if (filteredList.Count == 0) throw new Exception("No filesets found on remote target"); - Exception lastEx = new Exception("No suitable files found on remote target"); + var lastEx = new Exception("No suitable files found on remote target"); foreach (var fileversion in filteredList) try { - if (!m_result.TaskControl.ProgressRendevouz().Await()) + if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) { - backendManager.WaitForEmptyAsync(db, null, cancellationToken).Await(); + await backendManager.WaitForEmptyAsync(db, null, m_result.TaskControl.ProgressToken).ConfigureAwait(false); return; } @@ -72,14 +73,14 @@ namespace Duplicati.Library.Main.Operation var entry = db.GetRemoteVolume(file.Name); var res = new List(); - using (var tmpfile = backendManager.GetAsync(file.Name, entry.Hash, entry.Size < 0 ? file.Size : entry.Size, cancellationToken).Await()) + using (var tmpfile = await backendManager.GetAsync(file.Name, entry.Hash, entry.Size < 0 ? file.Size : entry.Size, m_result.TaskControl.ProgressToken).ConfigureAwait(false)) using (var tmp = new Volumes.FilesetVolumeReader(RestoreHandler.GetCompressionModule(file.Name), tmpfile, m_options)) foreach (var cf in tmp.ControlFiles) - if (Library.Utility.FilterExpression.Matches(filter, cf.Key)) + if (FilterExpression.Matches(filter, cf.Key)) { - var targetpath = System.IO.Path.Combine(m_options.Restorepath, cf.Key); - using (var ts = System.IO.File.Create(targetpath)) - Library.Utility.Utility.CopyStream(cf.Value, ts); + var targetpath = Path.Combine(m_options.Restorepath, cf.Key); + using (var ts = File.Create(targetpath)) + await Library.Utility.Utility.CopyStreamAsync(cf.Value, ts, m_result.TaskControl.ProgressToken).ConfigureAwait(false); res.Add(targetpath); } @@ -100,7 +101,7 @@ namespace Duplicati.Library.Main.Operation } finally { - backendManager.WaitForEmptyAsync(db, null, cancellationToken).Await(); + await backendManager.WaitForEmptyAsync(db, null, m_result.TaskControl.ProgressToken).ConfigureAwait(false); } db.WriteResults(); diff --git a/Duplicati/Library/Main/Operation/RestoreHandler.cs b/Duplicati/Library/Main/Operation/RestoreHandler.cs index 73620a62f..f6cc80f48 100644 --- a/Duplicati/Library/Main/Operation/RestoreHandler.cs +++ b/Duplicati/Library/Main/Operation/RestoreHandler.cs @@ -1,1173 +1,1182 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -using System; -using System.Collections.Generic; -using System.Linq; -using Duplicati.Library.Interface; -using Duplicati.Library.Common.IO; -using Duplicati.Library.Main.Database; -using Duplicati.Library.Main.Volumes; -using Duplicati.Library.Utility; -using CoCoL; -using System.Threading.Tasks; -using System.Threading; - -namespace Duplicati.Library.Main.Operation -{ - internal class RestoreHandler - { - /// - /// The tag used for logging - /// - private static readonly string LOGTAG = Logging.Log.LogTagFromType(); - - private readonly Options m_options; - private byte[] m_blockbuffer; - private readonly RestoreResults m_result; - private static readonly string DIRSEP = Util.DirectorySeparatorString; - - public RestoreHandler(Options options, RestoreResults result) - { - m_options = options; - m_result = result; - } - - /// - /// Gets the compression module by parsing the filename - /// - /// The filename to parse - /// The compression module - public static string GetCompressionModule(string filename) - { - var tmp = VolumeBase.ParseFilename(filename); - if (tmp == null) - throw new UserInformationException(string.Format("Unable to parse filename to valid entry: {0}", filename), "FailedToParseRemoteName"); - - return tmp.CompressionModule; - } - - public static RecreateDatabaseHandler.NumberedFilterFilelistDelegate FilterNumberedFilelist(DateTime time, long[] versions, bool singleTimeMatch = false) - { - if (time.Kind == DateTimeKind.Unspecified) - throw new Exception("Unspecified datetime instance, must be either local or UTC"); - - // Make sure the resolution is the same (i.e. no milliseconds) - if (time.Ticks > 0) - time = Library.Utility.Utility.DeserializeDateTime(Library.Utility.Utility.SerializeDateTime(time)).ToUniversalTime(); - - return - _lst => - { - // Unwrap, so we do not query the remote storage twice - var lst = (from n in _lst - where n.FileType == RemoteVolumeType.Files - orderby n.Time descending - select n).ToArray(); - - var numbers = lst.Zip(Enumerable.Range(0, lst.Length), (a, b) => new KeyValuePair(b, a)).ToList(); - - if (time.Ticks > 0 && versions != null && versions.Length > 0) - return from n in numbers - where (singleTimeMatch ? n.Value.Time == time : n.Value.Time <= time) && versions.Contains(n.Key) - select n; - else if (time.Ticks > 0) - return from n in numbers - where (singleTimeMatch ? n.Value.Time == time : n.Value.Time <= time) - select n; - else if (versions != null && versions.Length > 0) - return from n in numbers - where versions.Contains(n.Key) - select n; - else - return numbers; - }; - } - - public void Run(string[] paths, IBackendManager backendManager, Library.Utility.IFilter filter) - { - m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_Begin); - - // If we have both target paths and a filter, combine them into a single filter - filter = JoinedFilterExpression.Join(new FilterExpression(paths), filter); - - LocalRestoreDatabase db = null; - TempFile tmpdb = null; - try - { - if (!m_options.NoLocalDb && SystemIO.IO_OS.FileExists(m_options.Dbpath)) - { - db = new LocalRestoreDatabase(m_options.Dbpath); - db.SetResult(m_result); - } - else - { - Logging.Log.WriteInformationMessage(LOGTAG, "NoLocalDatabase", "No local database, building a temporary database"); - tmpdb = new TempFile(); - RecreateDatabaseHandler.NumberedFilterFilelistDelegate filelistfilter = FilterNumberedFilelist(m_options.Time, m_options.Version); - db = new LocalRestoreDatabase(tmpdb); - m_result.RecreateDatabaseResults = new RecreateDatabaseResults(m_result); - using (new Logging.Timer(LOGTAG, "RecreateTempDbForRestore", "Recreate temporary database for restore")) - new RecreateDatabaseHandler(m_options, (RecreateDatabaseResults)m_result.RecreateDatabaseResults) - .DoRun(backendManager, db, false, filter, filelistfilter, null).Await(); - - if (!m_options.SkipMetadata) - ApplyStoredMetadata(m_options, new RestoreHandlerMetadataStorage()); - - //If we have --version set, we need to adjust, as the db has only the required versions - //TODO: Bit of a hack to set options that way - if (m_options.Version != null && m_options.Version.Length > 0) - m_options.RawOptions["version"] = string.Join(",", Enumerable.Range(0, m_options.Version.Length).Select(x => x.ToString())); - } - - if (m_options.RestoreLegacy) - DoRun(backendManager, db, filter, m_result.TaskControl.ProgressToken).Await(); - else - DoRunNew(backendManager, db, filter, m_result.TaskControl.ProgressToken).Await(); - - db.WriteResults(); - } - finally - { - db?.Dispose(); - tmpdb?.Dispose(); - } - } - - private static void PatchWithBlocklist(LocalRestoreDatabase database, BlockVolumeReader blocks, Options options, RestoreResults result, byte[] blockbuffer, RestoreHandlerMetadataStorage metadatastorage) - { - var blocksize = options.Blocksize; - var updateCounter = 0L; - var fullblockverification = options.FullBlockVerification; - - using (var blockhasher = HashFactory.CreateHasher(options.BlockHashAlgorithm)) - using (var blockmarker = database.CreateBlockMarker()) - using (var volumekeeper = database.GetMissingBlockData(blocks, options.Blocksize)) - { - foreach (var restorelist in volumekeeper.FilesWithMissingBlocks) - { - var targetpath = restorelist.Path; - - if (options.Dryrun) - { - Logging.Log.WriteDryrunMessage(LOGTAG, "WouldPatchFile", "Would patch file with remote data: {0}", targetpath); - } - else - { - Logging.Log.WriteVerboseMessage(LOGTAG, "PatchingFile", "Patching file with remote data: {0}", targetpath); - - try - { - var folderpath = SystemIO.IO_OS.PathGetDirectoryName(targetpath); - if (!options.Dryrun && !SystemIO.IO_OS.DirectoryExists(folderpath)) - { - Logging.Log.WriteWarningMessage(LOGTAG, "CreateMissingFolder", null, "Creating missing folder {0} for file {1}", folderpath, targetpath); - SystemIO.IO_OS.DirectoryCreate(folderpath); - } - - // TODO: Much faster if we iterate the volume and checks what blocks are used, - // because the compressors usually like sequential reading - using (var file = SystemIO.IO_OS.FileOpenWrite(targetpath)) - foreach (var targetblock in restorelist.Blocks) - { - file.Position = targetblock.Offset; - var size = blocks.ReadBlock(targetblock.Key, blockbuffer); - if (targetblock.Size == size) - { - var valid = !fullblockverification; - if (!valid) - { - var key = Convert.ToBase64String(blockhasher.ComputeHash(blockbuffer, 0, size)); - if (targetblock.Key == key) - valid = true; - else - Logging.Log.WriteWarningMessage(LOGTAG, "InvalidBlock", null, "Invalid block detected for {0}, expected hash: {1}, actual hash: {2}", targetpath, targetblock.Key, key); - } - - if (valid) - { - file.Write(blockbuffer, 0, size); - blockmarker.SetBlockRestored(restorelist.FileID, targetblock.Offset / blocksize, targetblock.Key, size, false); - } - } - else - { - Logging.Log.WriteWarningMessage(LOGTAG, "WrongBlockSize", null, "Block with hash {0} should have size {1} but has size {2}", targetblock.Key, targetblock.Size, size); - } - } - - if ((++updateCounter) % 20 == 0) - blockmarker.UpdateProcessed(result.OperationProgressUpdater); - } - catch (Exception ex) - { - Logging.Log.WriteWarningMessage(LOGTAG, "PatchFailed", ex, "Failed to patch file: \"{0}\", message: {1}, message: {1}", targetpath, ex.Message); - if (options.UnittestMode) - throw; - } - } - } - - if (!options.SkipMetadata) - { - foreach (var restoremetadata in volumekeeper.MetadataWithMissingBlocks) - { - var targetpath = restoremetadata.Path; - Logging.Log.WriteVerboseMessage(LOGTAG, "RecordingMetadata", "Recording metadata from remote data: {0}", targetpath); - - try - { - // TODO: When we support multi-block metadata this needs to deal with it - using (var ms = new System.IO.MemoryStream()) - { - foreach (var targetblock in restoremetadata.Blocks) - { - ms.Position = targetblock.Offset; - var size = blocks.ReadBlock(targetblock.Key, blockbuffer); - if (targetblock.Size == size) - { - ms.Write(blockbuffer, 0, size); - blockmarker.SetBlockRestored(restoremetadata.FileID, targetblock.Offset / blocksize, targetblock.Key, size, true); - } - } - - ms.Position = 0; - metadatastorage.Add(targetpath, ms); - //blockmarker.RecordMetadata(restoremetadata.FileID, ms); - } - } - catch (Exception ex) - { - Logging.Log.WriteWarningMessage(LOGTAG, "MetatdataRecordFailed", ex, "Failed to record metadata for file: \"{0}\", message: {1}", targetpath, ex.Message); - if (options.UnittestMode) - throw; - } - } - } - blockmarker.UpdateProcessed(result.OperationProgressUpdater); - blockmarker.Commit(); - } - } - - private static void ApplyStoredMetadata(Options options, RestoreHandlerMetadataStorage metadatastorage) - { - foreach (var metainfo in metadatastorage.Records) - { - var targetpath = metainfo.Key; - - if (options.Dryrun) - { - Logging.Log.WriteDryrunMessage(LOGTAG, "WouldPatchMetadata", "Would patch metadata with remote data: {0}", targetpath); - } - else - { - Logging.Log.WriteVerboseMessage(LOGTAG, "PatchingMetadata", "Patching metadata with remote data: {0}", targetpath); - try - { - var folderpath = Duplicati.Library.Utility.Utility.GetParent(targetpath, false); - if (!options.Dryrun && !SystemIO.IO_OS.DirectoryExists(folderpath)) - { - Logging.Log.WriteWarningMessage(LOGTAG, "CreateMissingFolder", null, "Creating missing folder {0} for target {1}", folderpath, targetpath); - SystemIO.IO_OS.DirectoryCreate(folderpath); - } - - ApplyMetadata(targetpath, metainfo.Value, options.RestorePermissions, options.RestoreSymlinkMetadata, options.Dryrun); - } - catch (Exception ex) - { - Logging.Log.WriteWarningMessage(LOGTAG, "MetadataWriteFailed", ex, "Failed to apply metadata to file: \"{0}\", message: {1}", targetpath, ex.Message); - if (options.UnittestMode) - throw; - } - } - } - } - - /// - /// Perform the restore operation. - /// This is the new implementation, which utilizes a CSP network of processes to perform the restore. - /// - /// The database containing information about the restore. - /// The filter of which files to restore. - private async Task DoRunNew(IBackendManager backendManager, LocalRestoreDatabase database, Library.Utility.IFilter filter, CancellationToken cancellationToken) - { - // Perform initial setup - Utility.UpdateOptionsFromDb(database, m_options); - Utility.VerifyOptionsAndUpdateDatabase(database, m_options); - - // Verify the backend if necessary - if (!m_options.NoBackendverification) - { - m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_PreRestoreVerify); - await FilelistProcessor.VerifyRemoteList(backendManager, m_options, database, m_result.BackendWriter, false, null).ConfigureAwait(false); - } - - // Prepare the block and file list and create the directory structure - m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_CreateFileList); - PrepareBlockAndFileList(database, m_options, filter, m_result); - m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_CreateTargetFolders); - await CreateDirectoryStructure(database, m_options, m_result).ConfigureAwait(false); - database.SetResult(m_result); - - // Create the channels between BlockManager and FileProcessor - var fileprocessor_requests = new Channel[m_options.RestoreFileProcessors].Select(_ => ChannelManager.CreateChannel(buffersize: Restore.Channels.BufferSize)).ToArray(); - var fileprocessor_responses = new Channel[m_options.RestoreFileProcessors].Select(_ => ChannelManager.CreateChannel(buffersize: Restore.Channels.BufferSize)).ToArray(); - - // Create the process network - Restore.Channels channels = new(); - var filelister = Restore.FileLister.Run(channels, database, m_options, m_result); - var fileprocessors = Enumerable.Range(0, m_options.RestoreFileProcessors).Select(i => Restore.FileProcessor.Run(channels, database, fileprocessor_requests[i], fileprocessor_responses[i], m_options, m_result)).ToArray(); - var blockmanager = Restore.BlockManager.Run(channels, database, m_options, fileprocessor_requests, fileprocessor_responses); - var volumecache = Restore.VolumeManager.Run(channels, database, backendManager, m_options, m_result); - var volumedownloaders = Enumerable.Range(0, m_options.RestoreVolumeDownloaders).Select(i => Restore.VolumeDownloader.Run(channels, database, m_options, m_result)).ToArray(); - var volumedecryptors = Enumerable.Range(0, m_options.RestoreVolumeDecryptors).Select(i => Restore.VolumeDecryptor.Run(channels, m_options)).ToArray(); - var volumedecompressors = Enumerable.Range(0, m_options.RestoreVolumeDecompressors).Select(i => Restore.VolumeDecompressor.Run(channels, m_options)).ToArray(); - - // Wait for the network to complete - Task[] all = - [ - filelister, - ..fileprocessors, - blockmanager, - volumecache, - ..volumedownloaders, - ..volumedecryptors, - ..volumedecompressors - ]; - - // Start the progress updater - using (var kill_updater = new CancellationTokenSource()) - { - var updater = Task.Run(async () => - { - while (!kill_updater.Token.IsCancellationRequested) - { - m_result.OperationProgressUpdater.UpdatefilesProcessed(m_result.RestoredFiles, m_result.SizeOfRestoredFiles); - await Task.Delay(1000, kill_updater.Token).ConfigureAwait(false); - } - }, kill_updater.Token); - - await Task.WhenAll(all).ConfigureAwait(false); - kill_updater.Cancel(); - } - - if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) - return; - - m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_PostRestoreVerify); - - // If any errors occurred, log them - if (m_result.BrokenRemoteFiles.Count > 0 || m_result.BrokenLocalFiles.Count > 0) - { - var nl = Environment.NewLine; - int maxN = 10; - long remoteFirstN = Math.Min(maxN, m_result.BrokenRemoteFiles.Count); - string remoteFirst = remoteFirstN < m_result.BrokenRemoteFiles.Count ? $"first {maxN} " : string.Empty; - long localFirstN = Math.Min(maxN, m_result.BrokenLocalFiles.Count); - string localFirst = localFirstN < m_result.BrokenLocalFiles.Count ? $"first {maxN} " : string.Empty; - - string remoteMessage = m_result.BrokenRemoteFiles.Count > 0 ? $"Failed to download {m_result.BrokenRemoteFiles.Count} remote files." : string.Empty; - string remoteList = m_result.BrokenRemoteFiles.Count > 0 ? $"The following {remoteFirst}remote files failed to download, which may be the cause:{nl}{string.Join(nl, m_result.BrokenRemoteFiles.Take(maxN))}{nl}" : string.Empty; - string localMessage = m_result.BrokenLocalFiles.Count > 0 ? $"Failed to restore {m_result.BrokenLocalFiles.Count} local files." : string.Empty; - string localList = m_result.BrokenLocalFiles.Count > 0 ? $"The following {localFirst}local files failed to restore:{nl}{string.Join(nl, m_result.BrokenLocalFiles.Take(maxN))}{nl}" : string.Empty; - - Logging.Log.WriteErrorMessage(LOGTAG, "RestoreFailures", null, $"{remoteMessage}{nl}{localMessage}{nl}{remoteList}{nl}{localList}"); - } - else if (m_result.RestoredFiles == 0) - Logging.Log.WriteWarningMessage(LOGTAG, "NoFilesRestored", null, "Restore completed without errors but no files were restored"); - - // Drop the temp tables - database.DropRestoreTable(); - await backendManager.WaitForEmptyAsync(database, null, CancellationToken.None).ConfigureAwait(false); - - // Report that the restore is complete - m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_Complete); - m_result.EndTime = DateTime.UtcNow; - } - - /// - /// Perform the restore operation. - /// This is the legacy implementation, which performs the restore in a single thread. Kept as in case the new implementation fails. - /// - /// The database containing information about the restore. - /// The filter of which files to restore. - private async Task DoRun(IBackendManager backendManager, LocalRestoreDatabase database, Library.Utility.IFilter filter, CancellationToken cancellationToken) - { - //In this case, we check that the remote storage fits with the database. - //We can then query the database and find the blocks that we need to do the restore - //using (var database = new LocalRestoreDatabase(dbparent)) - using (var metadatastorage = new RestoreHandlerMetadataStorage()) - { - database.SetResult(m_result); - Utility.UpdateOptionsFromDb(database, m_options); - Utility.VerifyOptionsAndUpdateDatabase(database, m_options); - m_blockbuffer = new byte[m_options.Blocksize]; - - if (!m_options.NoBackendverification) - { - m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_PreRestoreVerify); - await FilelistProcessor.VerifyRemoteList(backendManager, m_options, database, m_result.BackendWriter, false, null).ConfigureAwait(false); - } - - //Figure out what files are to be patched, and what blocks are needed - m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_CreateFileList); - using (new Logging.Timer(LOGTAG, "PrepareBlockList", "PrepareBlockList")) - PrepareBlockAndFileList(database, m_options, filter, m_result); - - //Make the entire output setup - m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_CreateTargetFolders); - using (new Logging.Timer(LOGTAG, "CreateDirectory", "CreateDirectory")) - await CreateDirectoryStructure(database, m_options, m_result); - - //If we are patching an existing target folder, do not touch stuff that is already updated - m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_ScanForExistingFiles); - using (var blockhasher = HashFactory.CreateHasher(m_options.BlockHashAlgorithm)) - using (var filehasher = HashFactory.CreateHasher(m_options.FileHashAlgorithm)) - using (new Logging.Timer(LOGTAG, "ScanForExistingTargetBlocks", "ScanForExistingTargetBlocks")) - await ScanForExistingTargetBlocks(database, m_blockbuffer, blockhasher, filehasher, m_options, m_result).ConfigureAwait(false); - - //Look for existing blocks in the original source files only - if (m_options.UseLocalBlocks && !string.IsNullOrEmpty(m_options.Restorepath)) - { - using (var blockhasher = HashFactory.CreateHasher(m_options.BlockHashAlgorithm)) - using (new Logging.Timer(LOGTAG, "ScanForExistingSourceBlocksFast", "ScanForExistingSourceBlocksFast")) - { - m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_ScanForLocalBlocks); - await ScanForExistingSourceBlocksFast(database, m_options, m_blockbuffer, blockhasher, m_result).ConfigureAwait(false); - } - } - - if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) - { - await backendManager.WaitForEmptyAsync(database, null, cancellationToken).ConfigureAwait(false); - return; - } - - // If other local files already have the blocks we want, we use them instead of downloading - if (m_options.UseLocalBlocks) - { - m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_PatchWithLocalBlocks); - using (var blockhasher = HashFactory.CreateHasher(m_options.BlockHashAlgorithm)) - using (new Logging.Timer(LOGTAG, "PatchWithLocalBlocks", "PatchWithLocalBlocks")) - await ScanForExistingSourceBlocks(database, m_options, m_blockbuffer, blockhasher, m_result, metadatastorage).ConfigureAwait(false); - } - - if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) - { - await backendManager.WaitForEmptyAsync(database, null, cancellationToken).ConfigureAwait(false); - return; - } - - // Fill BLOCKS with remote sources - List volumes; - using (new Logging.Timer(LOGTAG, "GetMissingVolumes", "GetMissingVolumes")) - volumes = database.GetMissingVolumes().ToList(); - - if (volumes.Count > 0) - { - Logging.Log.WriteInformationMessage(LOGTAG, "RemoteFileCount", "{0} remote files are required to restore", volumes.Count); - m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_DownloadingRemoteFiles); - } - - var brokenFiles = new List(); - - await foreach (var (tmpfile, _, _, name) in backendManager.GetFilesOverlappedAsync(volumes, cancellationToken)) - { - try - { - if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) - { - await backendManager.WaitForEmptyAsync(database, null, cancellationToken).ConfigureAwait(false); - return; - } - - using (tmpfile) - using (var blocks = new BlockVolumeReader(GetCompressionModule(name), tmpfile, m_options)) - PatchWithBlocklist(database, blocks, m_options, m_result, m_blockbuffer, metadatastorage); - } - catch (Exception ex) - { - brokenFiles.Add(name); - Logging.Log.WriteErrorMessage(LOGTAG, "PatchingFailed", ex, "Failed to patch with remote file: \"{0}\", message: {1}", name, ex.Message); - if (ex is System.Threading.ThreadAbortException) - throw; - } - } - - var fileErrors = 0L; - - // Restore empty files. They might not have any blocks so don't appear in any volume. - foreach (var file in database.GetFilesToRestore(true).Where(item => item.Length == 0)) - { - Logging.Log.WriteVerboseMessage(LOGTAG, "RestoreEmptyFile", "Restoring empty file \"{0}\"", file.Path); - - try - { - SystemIO.IO_OS.DirectoryCreate(SystemIO.IO_OS.PathGetDirectoryName(file.Path)); - // Just create the file and close it right away, empty statement is intentional. - using (SystemIO.IO_OS.FileCreate(file.Path)) - { - } - } - catch (Exception ex) - { - fileErrors++; - Logging.Log.WriteErrorMessage(LOGTAG, "RestoreFileFailed", ex, "Failed to restore empty file: \"{0}\". Error message was: {1}", file.Path, ex.Message); - if (ex is System.Threading.ThreadAbortException) - throw; - } - } - - // Enforcing the length of files is now already done during ScanForExistingTargetBlocks - // and thus not necessary anymore. - - // Apply metadata - if (!m_options.SkipMetadata) - ApplyStoredMetadata(m_options, metadatastorage); - - if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) - return; - - m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_PostRestoreVerify); - - - if (m_options.PerformRestoredFileVerification) - { - // After all blocks in the files are restored, verify the file hash - using (var filehasher = HashFactory.CreateHasher(m_options.FileHashAlgorithm)) - using (new Logging.Timer(LOGTAG, "RestoreVerification", "RestoreVerification")) - foreach (var file in database.GetFilesToRestore(true)) - { - try - { - if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) - { - await backendManager.WaitForEmptyAsync(database, null, cancellationToken).ConfigureAwait(false); - return; - } - - Logging.Log.WriteVerboseMessage(LOGTAG, "TestFileIntegrity", "Testing restored file integrity: {0}", file.Path); - - string key; - long size; - using (var fs = SystemIO.IO_OS.FileOpenRead(file.Path)) - { - size = fs.Length; - key = Convert.ToBase64String(filehasher.ComputeHash(fs)); - } - - if (key != file.Hash) - throw new Exception(string.Format("Failed to restore file: \"{0}\". File hash is {1}, expected hash is {2}", file.Path, key, file.Hash)); - m_result.RestoredFiles++; - m_result.SizeOfRestoredFiles += size; - } - catch (Exception ex) - { - fileErrors++; - Logging.Log.WriteErrorMessage(LOGTAG, "RestoreFileFailed", ex, "Failed to restore file: \"{0}\". Error message was: {1}", file.Path, ex.Message); - if (ex is System.Threading.ThreadAbortException) - throw; - } - } - } - - if (fileErrors > 0 && brokenFiles.Count > 0) - Logging.Log.WriteInformationMessage(LOGTAG, "RestoreFailures", "Failed to restore {0} files, additionally the following files failed to download, which may be the cause:{1}{2}", fileErrors, Environment.NewLine, string.Join(Environment.NewLine, brokenFiles)); - else if (fileErrors > 0) - Logging.Log.WriteInformationMessage(LOGTAG, "RestoreFailures", "Failed to restore {0} files", fileErrors); - else if (m_result.RestoredFiles == 0) - Logging.Log.WriteWarningMessage(LOGTAG, "NoFilesRestored", null, "Restore completed without errors but no files were restored"); - - // Drop the temp tables - database.DropRestoreTable(); - await backendManager.WaitForEmptyAsync(database, null, cancellationToken).ConfigureAwait(false); - } - - m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_Complete); - m_result.EndTime = DateTime.UtcNow; - } - - public static bool ApplyMetadata(string path, System.IO.Stream stream, bool restorePermissions, bool restoreSymlinkMetadata, bool dryrun) - { - // TODO This has been modified to return a bool indicating if anything was written to properly report the number of files restored. The legacy restore doesn't check this, which produces an error in the CI where it reports that no files have been restored, even though one have. It's in Duplicati/UnitTests/SymLinkTests.cs the test SymLinkTests.SymLinkExists() that fails on the very last assert that there are 0 warnings. - using (var tr = new System.IO.StreamReader(stream)) - using (var jr = new Newtonsoft.Json.JsonTextReader(tr)) - { - var metadata = new Newtonsoft.Json.JsonSerializer().Deserialize>(jr); - string k; - long t; - System.IO.FileAttributes fa; - var wrote_something = false; - - // If this is dry-run, we stop after having deserialized the metadata - if (dryrun) - return wrote_something; - - var isDirTarget = path.EndsWith(DIRSEP, StringComparison.Ordinal); - var targetpath = isDirTarget ? path.Substring(0, path.Length - 1) : path; - - // Make the symlink first, otherwise we cannot apply metadata to it - if (metadata.TryGetValue("CoreSymlinkTarget", out k)) - { - // Check if the target exists, and overwrite it if it does. - if (SystemIO.IO_OS.FileExists(targetpath)) - { - SystemIO.IO_OS.FileDelete(targetpath); - } - else if (SystemIO.IO_OS.DirectoryExists(targetpath)) - { - SystemIO.IO_OS.DirectoryDelete(targetpath, false); - } - SystemIO.IO_OS.CreateSymlink(targetpath, k, isDirTarget); - wrote_something = true; - } - // If the target is a folder, make sure we create it first - else if (isDirTarget && !SystemIO.IO_OS.DirectoryExists(targetpath)) - SystemIO.IO_OS.DirectoryCreate(targetpath); - - // Avoid setting restoring symlink metadata, as that writes the symlink target, not the symlink itself - if (!restoreSymlinkMetadata && Snapshots.SnapshotUtility.IsSymlink(SystemIO.IO_OS, targetpath)) - { - Logging.Log.WriteVerboseMessage(LOGTAG, "no-symlink-metadata-restored", "Not applying metadata to symlink: {0}", targetpath); - return wrote_something; - } - - if (metadata.TryGetValue("CoreLastWritetime", out k) && long.TryParse(k, out t)) - { - if (isDirTarget) - SystemIO.IO_OS.DirectorySetLastWriteTimeUtc(targetpath, new DateTime(t, DateTimeKind.Utc)); - else - SystemIO.IO_OS.FileSetLastWriteTimeUtc(targetpath, new DateTime(t, DateTimeKind.Utc)); - } - - if (metadata.TryGetValue("CoreCreatetime", out k) && long.TryParse(k, out t)) - { - if (isDirTarget) - SystemIO.IO_OS.DirectorySetCreationTimeUtc(targetpath, new DateTime(t, DateTimeKind.Utc)); - else - SystemIO.IO_OS.FileSetCreationTimeUtc(targetpath, new DateTime(t, DateTimeKind.Utc)); - } - - if (metadata.TryGetValue("CoreAttributes", out k) && Enum.TryParse(k, true, out fa)) - SystemIO.IO_OS.SetFileAttributes(targetpath, fa); - - SystemIO.IO_OS.SetMetadata(path, metadata, restorePermissions); - return wrote_something; - } - } - - private static async Task ScanForExistingSourceBlocksFast(LocalRestoreDatabase database, Options options, byte[] blockbuffer, System.Security.Cryptography.HashAlgorithm hasher, RestoreResults result) - { - // Fill BLOCKS with data from known local source files - using (var blockmarker = database.CreateBlockMarker()) - { - var updateCount = 0L; - foreach (var entry in database.GetFilesAndSourceBlocksFast(options.Blocksize)) - { - var targetpath = entry.TargetPath; - var targetfileid = entry.TargetFileID; - var sourcepath = entry.SourcePath; - var patched = false; - - try - { - if (SystemIO.IO_OS.FileExists(sourcepath)) - { - var folderpath = SystemIO.IO_OS.PathGetDirectoryName(targetpath); - if (!options.Dryrun && !SystemIO.IO_OS.DirectoryExists(folderpath)) - { - Logging.Log.WriteWarningMessage(LOGTAG, "CreateMissingFolder", null, "Creating missing folder {0} for file {1}", folderpath, targetpath); - SystemIO.IO_OS.DirectoryCreate(folderpath); - } - - using (var targetstream = options.Dryrun ? null : SystemIO.IO_OS.FileOpenWrite(targetpath)) - { - try - { - using (var sourcestream = SystemIO.IO_OS.FileOpenRead(sourcepath)) - { - foreach (var block in entry.Blocks) - { - if (!await result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) - return; - - //TODO: Handle metadata - - if (sourcestream.Length > block.Offset) - { - sourcestream.Position = block.Offset; - - int size = Library.Utility.Utility.ForceStreamRead(sourcestream, blockbuffer, blockbuffer.Length); - if (size == block.Size) - { - var key = Convert.ToBase64String(hasher.ComputeHash(blockbuffer, 0, size)); - if (key == block.Hash) - { - patched = true; - if (!options.Dryrun) - { - targetstream.Position = block.Offset; - targetstream.Write(blockbuffer, 0, size); - } - - blockmarker.SetBlockRestored(targetfileid, block.Index, key, block.Size, false); - } - } - } - } - } - } - catch (Exception ex) - { - Logging.Log.WriteWarningMessage(LOGTAG, "PatchingFileLocalFailed", ex, "Failed to patch file: \"{0}\" with data from local file \"{1}\", message: {2}", targetpath, sourcepath, ex.Message); - if (ex is System.Threading.ThreadAbortException) - throw; - } - } - - if ((++updateCount) % 20 == 0) - { - blockmarker.UpdateProcessed(result.OperationProgressUpdater); - if (!await result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) - return; - } - - } - else - { - Logging.Log.WriteVerboseMessage(LOGTAG, "LocalSourceMissing", "Local source file not found: {0}", sourcepath); - } - } - catch (Exception ex) - { - Logging.Log.WriteWarningMessage(LOGTAG, "PatchingFileLocalFailed", ex, "Failed to patch file: \"{0}\" with local data, message: {1}", targetpath, ex.Message); - if (ex is System.Threading.ThreadAbortException) - throw; - if (options.UnittestMode) - throw; - } - - if (patched) - Logging.Log.WriteVerboseMessage(LOGTAG, "FilePatchedWithLocal", "Target file is patched with some local data: {0}", targetpath); - else - Logging.Log.WriteVerboseMessage(LOGTAG, "FilePatchedWithLocal", "Target file is not patched any local data: {0}", targetpath); - - if (patched && options.Dryrun) - Logging.Log.WriteDryrunMessage(LOGTAG, "WouldPatchWithLocal", "Would patch file with local data: {0}", targetpath); - } - - blockmarker.UpdateProcessed(result.OperationProgressUpdater); - blockmarker.Commit(); - } - } - - private static async Task ScanForExistingSourceBlocks(LocalRestoreDatabase database, Options options, byte[] blockbuffer, System.Security.Cryptography.HashAlgorithm hasher, RestoreResults result, RestoreHandlerMetadataStorage metadatastorage) - { - // Fill BLOCKS with data from known local source files - using (var blockmarker = database.CreateBlockMarker()) - { - var updateCount = 0L; - foreach (var restorelist in database.GetFilesAndSourceBlocks(options.SkipMetadata, options.Blocksize)) - { - var targetpath = restorelist.TargetPath; - var targetfileid = restorelist.TargetFileID; - var patched = false; - try - { - if (!await result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) - return; - - var folderpath = SystemIO.IO_OS.PathGetDirectoryName(targetpath); - if (!options.Dryrun && !SystemIO.IO_OS.DirectoryExists(folderpath)) - { - Logging.Log.WriteWarningMessage(LOGTAG, "CreateMissingFolder", null, "Creating missing folder {0} for file {1}", folderpath, targetpath); - SystemIO.IO_OS.DirectoryCreate(folderpath); - } - - using (var file = options.Dryrun ? null : SystemIO.IO_OS.FileOpenWrite(targetpath)) - foreach (var targetblock in restorelist.Blocks) - { - foreach (var source in targetblock.Blocksources) - { - try - { - if (!await result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) - return; - - if (SystemIO.IO_OS.FileExists(source.Path)) - { - if (source.IsMetadata) - { - // TODO: Handle this by reconstructing - // metadata from file and checking the hash - - continue; - } - else - { - using (var sourcefile = SystemIO.IO_OS.FileOpenRead(source.Path)) - { - sourcefile.Position = source.Offset; - int size = Library.Utility.Utility.ForceStreamRead(sourcefile, blockbuffer, blockbuffer.Length); - if (size == targetblock.Size) - { - var key = Convert.ToBase64String(hasher.ComputeHash(blockbuffer, 0, size)); - if (key == targetblock.Hash) - { - if (!options.Dryrun) - { - if (targetblock.IsMetadata) - metadatastorage.Add(targetpath, new System.IO.MemoryStream(blockbuffer, 0, size)); - else - { - file.Position = targetblock.Offset; - file.Write(blockbuffer, 0, size); - } - } - - blockmarker.SetBlockRestored(targetfileid, targetblock.Index, key, targetblock.Size, false); - patched = true; - break; - } - } - } - } - } - } - catch (Exception ex) - { - Logging.Log.WriteWarningMessage(LOGTAG, "PatchingFileLocalFailed", ex, "Failed to patch file: \"{0}\" with data from local file \"{1}\", message: {2}", targetpath, source.Path, ex.Message); - if (ex is System.Threading.ThreadAbortException) - throw; - } - } - } - - if ((++updateCount) % 20 == 0) - blockmarker.UpdateProcessed(result.OperationProgressUpdater); - } - catch (Exception ex) - { - Logging.Log.WriteWarningMessage(LOGTAG, "PatchingFileLocalFailed", ex, "Failed to patch file: \"{0}\" with local data, message: {1}", targetpath, ex.Message); - if (options.UnittestMode) - throw; - } - - if (patched) - Logging.Log.WriteVerboseMessage(LOGTAG, "FilePatchedWithLocal", "Target file is patched with some local data: {0}", targetpath); - else - Logging.Log.WriteVerboseMessage(LOGTAG, "FilePatchedWithLocal", "Target file is not patched any local data: {0}", targetpath); - - if (patched && options.Dryrun) - Logging.Log.WriteDryrunMessage(LOGTAG, "WouldPatchWithLocal", string.Format("Would patch file with local data: {0}", targetpath)); - } - - blockmarker.UpdateProcessed(result.OperationProgressUpdater); - blockmarker.Commit(); - } - } - - private static void PrepareBlockAndFileList(LocalRestoreDatabase database, Options options, Library.Utility.IFilter filter, RestoreResults result) - { - // Create a temporary table FILES by selecting the files from fileset that matches a specific operation id - // Delete all entries from the temp table that are excluded by the filter(s) - using (new Logging.Timer(LOGTAG, "PrepareRestoreFileList", "PrepareRestoreFileList")) - { - var c = database.PrepareRestoreFilelist(options.Time, options.Version, filter); - result.OperationProgressUpdater.UpdatefileCount(c.Item1, c.Item2, true); - } - - using (new Logging.Timer(LOGTAG, "SetTargetPaths", "SetTargetPaths")) - if (!string.IsNullOrEmpty(options.Restorepath)) - { - // Find the largest common prefix - var largest_prefix = options.DontCompressRestorePaths ? "" : database.GetLargestPrefix(); - - Logging.Log.WriteVerboseMessage(LOGTAG, "MappingRestorePath", "Mapping restore path prefix to \"{0}\" to \"{1}\"", largest_prefix, Util.AppendDirSeparator(options.Restorepath)); - - // Set the target paths, special care with C:\ and / - database.SetTargetPaths(largest_prefix, Util.AppendDirSeparator(options.Restorepath)); - } - else - { - database.SetTargetPaths("", ""); - } - - // Create a temporary table BLOCKS that lists all blocks that needs to be recovered - using (new Logging.Timer(LOGTAG, "FindMissingBlocks", "FindMissingBlocks")) - database.FindMissingBlocks(options.SkipMetadata); - - // Create temporary tables and triggers that automatically track progress - using (new Logging.Timer(LOGTAG, "CreateProgressTracker", "CreateProgressTracker")) - database.CreateProgressTracker(false); - - } - - private static async Task CreateDirectoryStructure(LocalRestoreDatabase database, Options options, RestoreResults result) - { - // This part is not protected by try/catch as we need the target folder to exist - if (!string.IsNullOrEmpty(options.Restorepath)) - if (!SystemIO.IO_OS.DirectoryExists(options.Restorepath)) - { - Logging.Log.WriteVerboseMessage(LOGTAG, "CreateFolder", "Creating folder: {0}", options.Restorepath); - - if (options.Dryrun) - Logging.Log.WriteDryrunMessage(LOGTAG, "WouldCreateFolder", "Would create folder: {0}", options.Restorepath); - else - SystemIO.IO_OS.DirectoryCreate(options.Restorepath); - } - - foreach (var folder in database.GetTargetFolders()) - { - try - { - if (!await result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) - return; - - if (!SystemIO.IO_OS.DirectoryExists(folder)) - { - result.RestoredFolders++; - - Logging.Log.WriteVerboseMessage(LOGTAG, "CreateFolder", "Creating folder: {0}", folder); - - if (options.Dryrun) - Logging.Log.WriteDryrunMessage(LOGTAG, "WouldCreateFolder", "Would create folder: {0}", folder); - else - SystemIO.IO_OS.DirectoryCreate(folder); - } - } - catch (Exception ex) - { - Logging.Log.WriteWarningMessage(LOGTAG, "FolderCreateFailed", ex, "Failed to create folder: \"{0}\", message: {1}", folder, ex.Message); - if (options.UnittestMode) - throw; - } - } - } - - private static async Task ScanForExistingTargetBlocks(LocalRestoreDatabase database, byte[] blockbuffer, System.Security.Cryptography.HashAlgorithm blockhasher, System.Security.Cryptography.HashAlgorithm filehasher, Options options, RestoreResults result) - { - // Scan existing files for existing BLOCKS - using (var blockmarker = database.CreateBlockMarker()) - { - var updateCount = 0L; - foreach (var restorelist in database.GetExistingFilesWithBlocks()) - { - var rename = !options.Overwrite; - var targetpath = restorelist.TargetPath; - var targetfileid = restorelist.TargetFileID; - var targetfilehash = restorelist.TargetHash; - var targetfilelength = restorelist.Length; - if (SystemIO.IO_OS.FileExists(targetpath)) - { - try - { - if (!await result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) - return; - - var currentfilelength = SystemIO.IO_OS.FileLength(targetpath); - var wasTruncated = false; - - // Adjust file length in overwrite mode if necessary (smaller is ok, will be extended during restore) - // We do it before scanning for blocks. This allows full verification on files that only needs to - // be truncated (i.e. forthwritten log files). - if (!rename && currentfilelength > targetfilelength) - { - var currentAttr = SystemIO.IO_OS.GetFileAttributes(targetpath); - if ((currentAttr & System.IO.FileAttributes.ReadOnly) != 0) // clear readonly attribute - { - if (options.Dryrun) - Logging.Log.WriteDryrunMessage(LOGTAG, "WouldResetReadOnlyAttribute", "Would reset read-only attribute on file: {0}", targetpath); - else SystemIO.IO_OS.SetFileAttributes(targetpath, currentAttr & ~System.IO.FileAttributes.ReadOnly); - } - if (options.Dryrun) - Logging.Log.WriteDryrunMessage(LOGTAG, "WouldTruncateFile", "Would truncate file '{0}' to length of {1:N0} bytes", targetpath, targetfilelength); - else - { - using (var file = SystemIO.IO_OS.FileOpenWrite(targetpath)) - file.SetLength(targetfilelength); - currentfilelength = targetfilelength; - } - wasTruncated = true; - } - - // If file size does not match and we have to rename on conflict, - // the whole scan can be skipped here because all blocks have to be restored anyway. - // For the other cases, we will check block and and file hashes and look for blocks - // to be restored and files that can already be verified. - if (!rename || currentfilelength == targetfilelength) - { - // a file hash for verification will only be necessary if the file has exactly - // the wanted size so we have a chance to already mark the file as data-verified. - bool calcFileHash = (currentfilelength == targetfilelength); - if (calcFileHash) filehasher.Initialize(); - - using (var file = SystemIO.IO_OS.FileOpenRead(targetpath)) - using (var block = new Blockprocessor(file, blockbuffer)) - foreach (var targetblock in restorelist.Blocks) - { - var size = block.Readblock(); - if (size <= 0) - break; - - //TODO: Handle Metadata - - bool blockhashmatch = false; - if (size == targetblock.Size) - { - // Parallelize file hash calculation on rename. Running read-only on same array should not cause conflicts or races. - // Actually, in future always calculate the file hash and mark the file data as already verified. - - System.Threading.Tasks.Task calcFileHashTask = null; - if (calcFileHash) - calcFileHashTask = System.Threading.Tasks.Task.Run( - () => filehasher.TransformBlock(blockbuffer, 0, size, blockbuffer, 0)); - - var key = Convert.ToBase64String(blockhasher.ComputeHash(blockbuffer, 0, size)); - - if (calcFileHashTask != null) await calcFileHashTask.ConfigureAwait(false); // wait because blockbuffer will be overwritten. - - if (key == targetblock.Hash) - { - blockmarker.SetBlockRestored(targetfileid, targetblock.Index, key, size, false); - blockhashmatch = true; - } - } - if (calcFileHash && !blockhashmatch) // will not be necessary anymore - { - filehasher.TransformFinalBlock(blockbuffer, 0, 0); // So a new initialize will not throw - calcFileHash = false; - if (rename) // file does not match. So break. - break; - } - } - - bool fullfilehashmatch = false; - if (calcFileHash) // now check if files are identical - { - filehasher.TransformFinalBlock(blockbuffer, 0, 0); - var filekey = Convert.ToBase64String(filehasher.Hash); - fullfilehashmatch = (filekey == targetfilehash); - } - - if (!rename && !fullfilehashmatch && !wasTruncated) // Reset read-only attribute (if set) to overwrite - { - var currentAttr = SystemIO.IO_OS.GetFileAttributes(targetpath); - if ((currentAttr & System.IO.FileAttributes.ReadOnly) != 0) - { - if (options.Dryrun) - Logging.Log.WriteDryrunMessage(LOGTAG, "WouldResetReadOnlyAttribyte", "Would reset read-only attribute on file: {0}", targetpath); - else SystemIO.IO_OS.SetFileAttributes(targetpath, currentAttr & ~System.IO.FileAttributes.ReadOnly); - } - } - - if (fullfilehashmatch) - { - //TODO: Check metadata to trigger rename? If metadata changed, it will still be restored for the file in-place. - blockmarker.SetFileDataVerified(targetfileid); - Logging.Log.WriteVerboseMessage(LOGTAG, "TargetExistsInCorrectVersion", "Target file exists{1} and is correct version: {0}", targetpath, wasTruncated ? " (but was truncated)" : ""); - rename = false; - } - else if (rename) - { - // The new file will have none of the correct blocks, - // even if the scanned file had some - blockmarker.SetAllBlocksMissing(targetfileid); - } - } - - if ((++updateCount) % 20 == 0) - { - blockmarker.UpdateProcessed(result.OperationProgressUpdater); - if (!await result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) - return; - } - } - catch (Exception ex) - { - Logging.Log.WriteWarningMessage(LOGTAG, "TargetFileReadError", ex, "Failed to read target file: \"{0}\", message: {1}", targetpath, ex.Message); - if (ex is System.Threading.ThreadAbortException) - throw; - if (options.UnittestMode) - throw; - } - } - else - { - Logging.Log.WriteVerboseMessage(LOGTAG, "MissingTargetFile", "Target file does not exist: {0}", targetpath); - rename = false; - } - - if (rename) - { - //Select a new filename - var ext = SystemIO.IO_OS.PathGetExtension(targetpath) ?? ""; - if (!string.IsNullOrEmpty(ext) && !ext.StartsWith(".", StringComparison.Ordinal)) - ext = "." + ext; - - // First we try with a simple date append, assuming that there are not many conflicts there - var newname = SystemIO.IO_OS.PathChangeExtension(targetpath, null) + "." + database.RestoreTime.ToLocalTime().ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture); - var tr = newname + ext; - var c = 0; - while (SystemIO.IO_OS.FileExists(tr) && c < 1000) - { - try - { - // If we have a file with the correct name, - // it is most likely the file we want - filehasher.Initialize(); - - string key; - using (var file = SystemIO.IO_OS.FileOpenRead(tr)) - key = Convert.ToBase64String(filehasher.ComputeHash(file)); - - if (key == targetfilehash) - { - //TODO: Also needs metadata check to make correct decision. - // We stick to the policy to restore metadata in place, if data ok. So, metadata block may be restored. - blockmarker.SetAllBlocksRestored(targetfileid, false); - blockmarker.SetFileDataVerified(targetfileid); - break; - } - } - catch (Exception ex) - { - Logging.Log.WriteWarningMessage(LOGTAG, "FailedToReadRestoreTarget", ex, "Failed to read candidate restore target {0}", tr); - if (options.UnittestMode) - throw; - } - tr = newname + " (" + (c++).ToString() + ")" + ext; - } - - newname = tr; - - Logging.Log.WriteVerboseMessage(LOGTAG, "TargetFileRetargeted", "Target file exists and will be restored to: {0}", newname); - database.UpdateTargetPath(targetfileid, newname); - } - - } - - blockmarker.UpdateProcessed(result.OperationProgressUpdater); - blockmarker.Commit(); - } - } - } -} +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.Linq; +using Duplicati.Library.Interface; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Main.Database; +using Duplicati.Library.Main.Volumes; +using Duplicati.Library.Utility; +using CoCoL; +using System.Threading.Tasks; +using System.Threading; + +namespace Duplicati.Library.Main.Operation +{ + internal class RestoreHandler + { + /// + /// The tag used for logging + /// + private static readonly string LOGTAG = Logging.Log.LogTagFromType(); + + private readonly Options m_options; + private byte[] m_blockbuffer; + private readonly RestoreResults m_result; + private static readonly string DIRSEP = Util.DirectorySeparatorString; + + public RestoreHandler(Options options, RestoreResults result) + { + m_options = options; + m_result = result; + } + + /// + /// Gets the compression module by parsing the filename + /// + /// The filename to parse + /// The compression module + public static string GetCompressionModule(string filename) + { + var tmp = VolumeBase.ParseFilename(filename); + if (tmp == null) + throw new UserInformationException(string.Format("Unable to parse filename to valid entry: {0}", filename), "FailedToParseRemoteName"); + + return tmp.CompressionModule; + } + + public static RecreateDatabaseHandler.NumberedFilterFilelistDelegate FilterNumberedFilelist(DateTime time, long[] versions, bool singleTimeMatch = false) + { + if (time.Kind == DateTimeKind.Unspecified) + throw new Exception("Unspecified datetime instance, must be either local or UTC"); + + // Make sure the resolution is the same (i.e. no milliseconds) + if (time.Ticks > 0) + time = Library.Utility.Utility.DeserializeDateTime(Library.Utility.Utility.SerializeDateTime(time)).ToUniversalTime(); + + return + _lst => + { + // Unwrap, so we do not query the remote storage twice + var lst = (from n in _lst + where n.FileType == RemoteVolumeType.Files + orderby n.Time descending + select n).ToArray(); + + var numbers = lst.Zip(Enumerable.Range(0, lst.Length), (a, b) => new KeyValuePair(b, a)).ToList(); + + if (time.Ticks > 0 && versions != null && versions.Length > 0) + return from n in numbers + where (singleTimeMatch ? n.Value.Time == time : n.Value.Time <= time) && versions.Contains(n.Key) + select n; + else if (time.Ticks > 0) + return from n in numbers + where (singleTimeMatch ? n.Value.Time == time : n.Value.Time <= time) + select n; + else if (versions != null && versions.Length > 0) + return from n in numbers + where versions.Contains(n.Key) + select n; + else + return numbers; + }; + } + + public async Task RunAsync(string[] paths, IBackendManager backendManager, Library.Utility.IFilter filter) + { + m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_Begin); + + // If we have both target paths and a filter, combine them into a single filter + filter = JoinedFilterExpression.Join(new FilterExpression(paths), filter); + + LocalRestoreDatabase db = null; + TempFile tmpdb = null; + try + { + if (!m_options.NoLocalDb && SystemIO.IO_OS.FileExists(m_options.Dbpath)) + { + db = new LocalRestoreDatabase(m_options.Dbpath); + db.SetResult(m_result); + } + else + { + Logging.Log.WriteInformationMessage(LOGTAG, "NoLocalDatabase", "No local database, building a temporary database"); + tmpdb = new TempFile(); + RecreateDatabaseHandler.NumberedFilterFilelistDelegate filelistfilter = FilterNumberedFilelist(m_options.Time, m_options.Version); + db = new LocalRestoreDatabase(tmpdb); + m_result.RecreateDatabaseResults = new RecreateDatabaseResults(m_result); + using (new Logging.Timer(LOGTAG, "RecreateTempDbForRestore", "Recreate temporary database for restore")) + await new RecreateDatabaseHandler(m_options, (RecreateDatabaseResults)m_result.RecreateDatabaseResults) + .DoRunAsync(backendManager, db, false, filter, filelistfilter, null) + .ConfigureAwait(false); + + if (!m_options.SkipMetadata) + ApplyStoredMetadata(m_options, new RestoreHandlerMetadataStorage()); + + //If we have --version set, we need to adjust, as the db has only the required versions + //TODO: Bit of a hack to set options that way + if (m_options.Version != null && m_options.Version.Length > 0) + m_options.RawOptions["version"] = string.Join(",", Enumerable.Range(0, m_options.Version.Length).Select(x => x.ToString())); + } + + if (m_options.RestoreLegacy) + await DoRunAsync(backendManager, db, filter, m_result.TaskControl.ProgressToken).ConfigureAwait(false); + else + await DoRunNewAsync(backendManager, db, filter, m_result.TaskControl.ProgressToken).ConfigureAwait(false); + + db.WriteResults(); + } + finally + { + db?.Dispose(); + tmpdb?.Dispose(); + } + } + + private static void PatchWithBlocklist(LocalRestoreDatabase database, BlockVolumeReader blocks, Options options, RestoreResults result, byte[] blockbuffer, RestoreHandlerMetadataStorage metadatastorage) + { + var blocksize = options.Blocksize; + var updateCounter = 0L; + var fullblockverification = options.FullBlockVerification; + + using (var blockhasher = HashFactory.CreateHasher(options.BlockHashAlgorithm)) + using (var blockmarker = database.CreateBlockMarker()) + using (var volumekeeper = database.GetMissingBlockData(blocks, options.Blocksize)) + { + foreach (var restorelist in volumekeeper.FilesWithMissingBlocks) + { + var targetpath = restorelist.Path; + + if (options.Dryrun) + { + Logging.Log.WriteDryrunMessage(LOGTAG, "WouldPatchFile", "Would patch file with remote data: {0}", targetpath); + } + else + { + Logging.Log.WriteVerboseMessage(LOGTAG, "PatchingFile", "Patching file with remote data: {0}", targetpath); + + try + { + var folderpath = SystemIO.IO_OS.PathGetDirectoryName(targetpath); + if (!options.Dryrun && !SystemIO.IO_OS.DirectoryExists(folderpath)) + { + Logging.Log.WriteWarningMessage(LOGTAG, "CreateMissingFolder", null, "Creating missing folder {0} for file {1}", folderpath, targetpath); + SystemIO.IO_OS.DirectoryCreate(folderpath); + } + + // TODO: Much faster if we iterate the volume and checks what blocks are used, + // because the compressors usually like sequential reading + using (var file = SystemIO.IO_OS.FileOpenWrite(targetpath)) + foreach (var targetblock in restorelist.Blocks) + { + file.Position = targetblock.Offset; + var size = blocks.ReadBlock(targetblock.Key, blockbuffer); + if (targetblock.Size == size) + { + var valid = !fullblockverification; + if (!valid) + { + var key = Convert.ToBase64String(blockhasher.ComputeHash(blockbuffer, 0, size)); + if (targetblock.Key == key) + valid = true; + else + Logging.Log.WriteWarningMessage(LOGTAG, "InvalidBlock", null, "Invalid block detected for {0}, expected hash: {1}, actual hash: {2}", targetpath, targetblock.Key, key); + } + + if (valid) + { + file.Write(blockbuffer, 0, size); + blockmarker.SetBlockRestored(restorelist.FileID, targetblock.Offset / blocksize, targetblock.Key, size, false); + } + } + else + { + Logging.Log.WriteWarningMessage(LOGTAG, "WrongBlockSize", null, "Block with hash {0} should have size {1} but has size {2}", targetblock.Key, targetblock.Size, size); + } + } + + if ((++updateCounter) % 20 == 0) + blockmarker.UpdateProcessed(result.OperationProgressUpdater); + } + catch (Exception ex) + { + Logging.Log.WriteWarningMessage(LOGTAG, "PatchFailed", ex, "Failed to patch file: \"{0}\", message: {1}, message: {1}", targetpath, ex.Message); + if (options.UnittestMode) + throw; + } + } + } + + if (!options.SkipMetadata) + { + foreach (var restoremetadata in volumekeeper.MetadataWithMissingBlocks) + { + var targetpath = restoremetadata.Path; + Logging.Log.WriteVerboseMessage(LOGTAG, "RecordingMetadata", "Recording metadata from remote data: {0}", targetpath); + + try + { + // TODO: When we support multi-block metadata this needs to deal with it + using (var ms = new System.IO.MemoryStream()) + { + foreach (var targetblock in restoremetadata.Blocks) + { + ms.Position = targetblock.Offset; + var size = blocks.ReadBlock(targetblock.Key, blockbuffer); + if (targetblock.Size == size) + { + ms.Write(blockbuffer, 0, size); + blockmarker.SetBlockRestored(restoremetadata.FileID, targetblock.Offset / blocksize, targetblock.Key, size, true); + } + } + + ms.Position = 0; + metadatastorage.Add(targetpath, ms); + //blockmarker.RecordMetadata(restoremetadata.FileID, ms); + } + } + catch (Exception ex) + { + Logging.Log.WriteWarningMessage(LOGTAG, "MetatdataRecordFailed", ex, "Failed to record metadata for file: \"{0}\", message: {1}", targetpath, ex.Message); + if (options.UnittestMode) + throw; + } + } + } + blockmarker.UpdateProcessed(result.OperationProgressUpdater); + blockmarker.Commit(); + } + } + + private static void ApplyStoredMetadata(Options options, RestoreHandlerMetadataStorage metadatastorage) + { + foreach (var metainfo in metadatastorage.Records) + { + var targetpath = metainfo.Key; + + if (options.Dryrun) + { + Logging.Log.WriteDryrunMessage(LOGTAG, "WouldPatchMetadata", "Would patch metadata with remote data: {0}", targetpath); + } + else + { + Logging.Log.WriteVerboseMessage(LOGTAG, "PatchingMetadata", "Patching metadata with remote data: {0}", targetpath); + try + { + var folderpath = Duplicati.Library.Utility.Utility.GetParent(targetpath, false); + if (!options.Dryrun && !SystemIO.IO_OS.DirectoryExists(folderpath)) + { + Logging.Log.WriteWarningMessage(LOGTAG, "CreateMissingFolder", null, "Creating missing folder {0} for target {1}", folderpath, targetpath); + SystemIO.IO_OS.DirectoryCreate(folderpath); + } + + ApplyMetadata(targetpath, metainfo.Value, options.RestorePermissions, options.RestoreSymlinkMetadata, options.Dryrun); + } + catch (Exception ex) + { + Logging.Log.WriteWarningMessage(LOGTAG, "MetadataWriteFailed", ex, "Failed to apply metadata to file: \"{0}\", message: {1}", targetpath, ex.Message); + if (options.UnittestMode) + throw; + } + } + } + } + + /// + /// Perform the restore operation. + /// This is the new implementation, which utilizes a CSP network of processes to perform the restore. + /// + /// The database containing information about the restore. + /// The filter of which files to restore. + private async Task DoRunNewAsync(IBackendManager backendManager, LocalRestoreDatabase database, Library.Utility.IFilter filter, CancellationToken cancellationToken) + { + // Perform initial setup + Utility.UpdateOptionsFromDb(database, m_options); + Utility.VerifyOptionsAndUpdateDatabase(database, m_options); + + // Verify the backend if necessary + if (!m_options.NoBackendverification) + { + m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_PreRestoreVerify); + await FilelistProcessor.VerifyRemoteList(backendManager, m_options, database, m_result.BackendWriter, latestVolumesOnly: false, verifyMode: FilelistProcessor.VerifyMode.VerifyOnly, null).ConfigureAwait(false); + } + + // Prepare the block and file list and create the directory structure + m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_CreateFileList); + using (new Logging.Timer(LOGTAG, "PrepareBlockList", "PrepareBlockList")) + PrepareBlockAndFileList(database, m_options, filter, m_result); + m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_CreateTargetFolders); + using (new Logging.Timer(LOGTAG, "CreateDirectory", "CreateDirectory")) + await CreateDirectoryStructure(database, m_options, m_result).ConfigureAwait(false); + database.SetResult(m_result); + + using var setup_log_timer = new Logging.Timer(LOGTAG, "RestoreNetworkSetup", "RestoreNetworkSetup"); + // Create the channels between BlockManager and FileProcessor + Restore.Channels.BufferSize = m_options.RestoreChannelBufferSize; + var fileprocessor_requests = Enumerable.Range(0, m_options.RestoreFileProcessors).Select(_ => ChannelManager.CreateChannel(buffersize: Restore.Channels.BufferSize)).ToArray(); + var fileprocessor_responses = Enumerable.Range(0, m_options.RestoreFileProcessors).Select(_ => ChannelManager.CreateChannel>(buffersize: Restore.Channels.BufferSize)).ToArray(); + + // Create the process network + Restore.Channels channels = new(); + var filelister = Restore.FileLister.Run(channels, database, m_options, m_result); + var fileprocessors = Enumerable.Range(0, m_options.RestoreFileProcessors).Select(i => Restore.FileProcessor.Run(channels, database, fileprocessor_requests[i], fileprocessor_responses[i], m_options, m_result)).ToArray(); + var blockmanager = Restore.BlockManager.Run(channels, database, m_options, fileprocessor_requests, fileprocessor_responses); + var volumecache = Restore.VolumeManager.Run(channels, m_options); + var volumedownloaders = Enumerable.Range(0, m_options.RestoreVolumeDownloaders).Select(i => Restore.VolumeDownloader.Run(channels, database, backendManager, m_options, m_result)).ToArray(); + var volumedecryptors = Enumerable.Range(0, m_options.RestoreVolumeDecryptors).Select(i => Restore.VolumeDecryptor.Run(channels, backendManager, m_options)).ToArray(); + var volumedecompressors = Enumerable.Range(0, m_options.RestoreVolumeDecompressors).Select(i => Restore.VolumeDecompressor.Run(channels, m_options)).ToArray(); + + setup_log_timer.Dispose(); + + // Wait for the network to complete + Task[] all = + [ + filelister, + ..fileprocessors, + blockmanager, + volumecache, + ..volumedownloaders, + ..volumedecryptors, + ..volumedecompressors + ]; + + // Start the progress updater + using (new Logging.Timer(LOGTAG, "RestoreNetworkWait", "RestoreNetworkWait")) + using (var kill_updater = new CancellationTokenSource()) + { + var updater = Task.Run(async () => + { + while (!kill_updater.Token.IsCancellationRequested) + { + m_result.OperationProgressUpdater.UpdatefilesProcessed(m_result.RestoredFiles, m_result.SizeOfRestoredFiles); + await Task.Delay(1000, kill_updater.Token).ConfigureAwait(false); + } + }, kill_updater.Token); + + await Task.WhenAll(all).ConfigureAwait(false); + kill_updater.Cancel(); + } + + if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) + return; + + m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_PostRestoreVerify); + + // If any errors occurred, log them + if (m_result.BrokenRemoteFiles.Count > 0 || m_result.BrokenLocalFiles.Count > 0) + { + var nl = Environment.NewLine; + int maxN = 10; + long remoteFirstN = Math.Min(maxN, m_result.BrokenRemoteFiles.Count); + string remoteFirst = remoteFirstN < m_result.BrokenRemoteFiles.Count ? $"first {maxN} " : string.Empty; + long localFirstN = Math.Min(maxN, m_result.BrokenLocalFiles.Count); + string localFirst = localFirstN < m_result.BrokenLocalFiles.Count ? $"first {maxN} " : string.Empty; + + string remoteMessage = m_result.BrokenRemoteFiles.Count > 0 ? $"Failed to download {m_result.BrokenRemoteFiles.Count} remote files." : string.Empty; + string remoteList = m_result.BrokenRemoteFiles.Count > 0 ? $"The following {remoteFirst}remote files failed to download, which may be the cause:{nl}{string.Join(nl, m_result.BrokenRemoteFiles.Take(maxN))}{nl}" : string.Empty; + string localMessage = m_result.BrokenLocalFiles.Count > 0 ? $"Failed to restore {m_result.BrokenLocalFiles.Count} local files." : string.Empty; + string localList = m_result.BrokenLocalFiles.Count > 0 ? $"The following {localFirst}local files failed to restore:{nl}{string.Join(nl, m_result.BrokenLocalFiles.Take(maxN))}{nl}" : string.Empty; + + Logging.Log.WriteErrorMessage(LOGTAG, "RestoreFailures", null, $"{remoteMessage}{nl}{localMessage}{nl}{remoteList}{nl}{localList}"); + } + else if (m_result.RestoredFiles == 0) + Logging.Log.WriteWarningMessage(LOGTAG, "NoFilesRestored", null, "Restore completed without errors but no files were restored"); + + // Drop the temp tables + database.DropRestoreTable(); + await backendManager.WaitForEmptyAsync(database, null, m_result.TaskControl.ProgressToken).ConfigureAwait(false); + + // Report that the restore is complete + m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_Complete); + m_result.EndTime = DateTime.UtcNow; + } + + /// + /// Perform the restore operation. + /// This is the legacy implementation, which performs the restore in a single thread. Kept as in case the new implementation fails. + /// + /// The database containing information about the restore. + /// The filter of which files to restore. + private async Task DoRunAsync(IBackendManager backendManager, LocalRestoreDatabase database, Library.Utility.IFilter filter, CancellationToken cancellationToken) + { + //In this case, we check that the remote storage fits with the database. + //We can then query the database and find the blocks that we need to do the restore + //using (var database = new LocalRestoreDatabase(dbparent)) + using (var metadatastorage = new RestoreHandlerMetadataStorage()) + { + database.SetResult(m_result); + Utility.UpdateOptionsFromDb(database, m_options); + Utility.VerifyOptionsAndUpdateDatabase(database, m_options); + m_blockbuffer = new byte[m_options.Blocksize]; + + if (!m_options.NoBackendverification) + { + m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_PreRestoreVerify); + await FilelistProcessor.VerifyRemoteList(backendManager, m_options, database, m_result.BackendWriter, latestVolumesOnly: false, verifyMode: FilelistProcessor.VerifyMode.VerifyOnly, null).ConfigureAwait(false); + } + + //Figure out what files are to be patched, and what blocks are needed + m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_CreateFileList); + using (new Logging.Timer(LOGTAG, "PrepareBlockList", "PrepareBlockList")) + PrepareBlockAndFileList(database, m_options, filter, m_result); + + //Make the entire output setup + m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_CreateTargetFolders); + using (new Logging.Timer(LOGTAG, "CreateDirectory", "CreateDirectory")) + await CreateDirectoryStructure(database, m_options, m_result); + + //If we are patching an existing target folder, do not touch stuff that is already updated + m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_ScanForExistingFiles); + using (var blockhasher = HashFactory.CreateHasher(m_options.BlockHashAlgorithm)) + using (var filehasher = HashFactory.CreateHasher(m_options.FileHashAlgorithm)) + using (new Logging.Timer(LOGTAG, "ScanForExistingTargetBlocks", "ScanForExistingTargetBlocks")) + await ScanForExistingTargetBlocks(database, m_blockbuffer, blockhasher, filehasher, m_options, m_result).ConfigureAwait(false); + + //Look for existing blocks in the original source files only + if (m_options.UseLocalBlocks && !string.IsNullOrEmpty(m_options.Restorepath)) + { + using (var blockhasher = HashFactory.CreateHasher(m_options.BlockHashAlgorithm)) + using (new Logging.Timer(LOGTAG, "ScanForExistingSourceBlocksFast", "ScanForExistingSourceBlocksFast")) + { + m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_ScanForLocalBlocks); + await ScanForExistingSourceBlocksFast(database, m_options, m_blockbuffer, blockhasher, m_result).ConfigureAwait(false); + } + } + + if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) + { + await backendManager.WaitForEmptyAsync(database, null, cancellationToken).ConfigureAwait(false); + return; + } + + // If other local files already have the blocks we want, we use them instead of downloading + if (m_options.UseLocalBlocks) + { + m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_PatchWithLocalBlocks); + using (var blockhasher = HashFactory.CreateHasher(m_options.BlockHashAlgorithm)) + using (new Logging.Timer(LOGTAG, "PatchWithLocalBlocks", "PatchWithLocalBlocks")) + await ScanForExistingSourceBlocks(database, m_options, m_blockbuffer, blockhasher, m_result, metadatastorage).ConfigureAwait(false); + } + + if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) + { + await backendManager.WaitForEmptyAsync(database, null, cancellationToken).ConfigureAwait(false); + return; + } + + // Fill BLOCKS with remote sources + List volumes; + using (new Logging.Timer(LOGTAG, "GetMissingVolumes", "GetMissingVolumes")) + volumes = database.GetMissingVolumes().ToList(); + + if (volumes.Count > 0) + { + Logging.Log.WriteInformationMessage(LOGTAG, "RemoteFileCount", "{0} remote files are required to restore", volumes.Count); + m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_DownloadingRemoteFiles); + } + + var brokenFiles = new List(); + + using (new Logging.Timer(LOGTAG, "PatchWithBlocklist", "PatchWithBlocklist")) + await foreach (var (tmpfile, _, _, name) in backendManager.GetFilesOverlappedAsync(volumes, cancellationToken).ConfigureAwait(false)) + { + try + { + if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) + { + await backendManager.WaitForEmptyAsync(database, null, cancellationToken).ConfigureAwait(false); + return; + } + + using (tmpfile) + using (var blocks = new BlockVolumeReader(GetCompressionModule(name), tmpfile, m_options)) + PatchWithBlocklist(database, blocks, m_options, m_result, m_blockbuffer, metadatastorage); + } + catch (Exception ex) + { + brokenFiles.Add(name); + Logging.Log.WriteErrorMessage(LOGTAG, "PatchingFailed", ex, "Failed to patch with remote file: \"{0}\", message: {1}", name, ex.Message); + if (ex is System.Threading.ThreadAbortException) + throw; + } + } + + var fileErrors = 0L; + + // Restore empty files. They might not have any blocks so don't appear in any volume. + foreach (var file in database.GetFilesToRestore(true).Where(item => item.Length == 0)) + { + Logging.Log.WriteVerboseMessage(LOGTAG, "RestoreEmptyFile", "Restoring empty file \"{0}\"", file.Path); + + try + { + SystemIO.IO_OS.DirectoryCreate(SystemIO.IO_OS.PathGetDirectoryName(file.Path)); + // Just create the file and close it right away, empty statement is intentional. + using (SystemIO.IO_OS.FileCreate(file.Path)) + { + } + } + catch (Exception ex) + { + fileErrors++; + Logging.Log.WriteErrorMessage(LOGTAG, "RestoreFileFailed", ex, "Failed to restore empty file: \"{0}\". Error message was: {1}", file.Path, ex.Message); + if (ex is System.Threading.ThreadAbortException) + throw; + } + } + + // Enforcing the length of files is now already done during ScanForExistingTargetBlocks + // and thus not necessary anymore. + + // Apply metadata + if (!m_options.SkipMetadata) + ApplyStoredMetadata(m_options, metadatastorage); + + if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) + return; + + m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_PostRestoreVerify); + + + if (m_options.PerformRestoredFileVerification) + { + // After all blocks in the files are restored, verify the file hash + using (var filehasher = HashFactory.CreateHasher(m_options.FileHashAlgorithm)) + using (new Logging.Timer(LOGTAG, "RestoreVerification", "RestoreVerification")) + foreach (var file in database.GetFilesToRestore(true)) + { + try + { + if (!await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) + { + await backendManager.WaitForEmptyAsync(database, null, cancellationToken).ConfigureAwait(false); + return; + } + + Logging.Log.WriteVerboseMessage(LOGTAG, "TestFileIntegrity", "Testing restored file integrity: {0}", file.Path); + + string key; + long size; + using (var fs = SystemIO.IO_OS.FileOpenRead(file.Path)) + { + size = fs.Length; + key = Convert.ToBase64String(filehasher.ComputeHash(fs)); + } + + if (key != file.Hash) + throw new Exception(string.Format("Failed to restore file: \"{0}\". File hash is {1}, expected hash is {2}", file.Path, key, file.Hash)); + m_result.RestoredFiles++; + m_result.SizeOfRestoredFiles += size; + } + catch (Exception ex) + { + fileErrors++; + Logging.Log.WriteErrorMessage(LOGTAG, "RestoreFileFailed", ex, "Failed to restore file: \"{0}\". Error message was: {1}", file.Path, ex.Message); + if (ex is System.Threading.ThreadAbortException) + throw; + } + } + } + + if (fileErrors > 0 && brokenFiles.Count > 0) + Logging.Log.WriteInformationMessage(LOGTAG, "RestoreFailures", "Failed to restore {0} files, additionally the following files failed to download, which may be the cause:{1}{2}", fileErrors, Environment.NewLine, string.Join(Environment.NewLine, brokenFiles)); + else if (fileErrors > 0) + Logging.Log.WriteInformationMessage(LOGTAG, "RestoreFailures", "Failed to restore {0} files", fileErrors); + else if (m_result.RestoredFiles == 0) + Logging.Log.WriteWarningMessage(LOGTAG, "NoFilesRestored", null, "Restore completed without errors but no files were restored"); + + // Drop the temp tables + database.DropRestoreTable(); + await backendManager.WaitForEmptyAsync(database, null, cancellationToken).ConfigureAwait(false); + } + + m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_Complete); + m_result.EndTime = DateTime.UtcNow; + } + + public static bool ApplyMetadata(string path, System.IO.Stream stream, bool restorePermissions, bool restoreSymlinkMetadata, bool dryrun) + { + // TODO This has been modified to return a bool indicating if anything was written to properly report the number of files restored. The legacy restore doesn't check this, which produces an error in the CI where it reports that no files have been restored, even though one have. It's in Duplicati/UnitTests/SymLinkTests.cs the test SymLinkTests.SymLinkExists() that fails on the very last assert that there are 0 warnings. + using (var tr = new System.IO.StreamReader(stream)) + using (var jr = new Newtonsoft.Json.JsonTextReader(tr)) + { + var metadata = new Newtonsoft.Json.JsonSerializer().Deserialize>(jr); + string k; + long t; + System.IO.FileAttributes fa; + var wrote_something = false; + + // If this is dry-run, we stop after having deserialized the metadata + if (dryrun) + return wrote_something; + + var isDirTarget = path.EndsWith(DIRSEP, StringComparison.Ordinal); + var targetpath = isDirTarget ? path.Substring(0, path.Length - 1) : path; + + // Make the symlink first, otherwise we cannot apply metadata to it + if (metadata.TryGetValue("CoreSymlinkTarget", out k)) + { + // Check if the target exists, and overwrite it if it does. + if (SystemIO.IO_OS.FileExists(targetpath)) + { + SystemIO.IO_OS.FileDelete(targetpath); + } + else if (SystemIO.IO_OS.DirectoryExists(targetpath)) + { + SystemIO.IO_OS.DirectoryDelete(targetpath, false); + } + SystemIO.IO_OS.CreateSymlink(targetpath, k, isDirTarget); + wrote_something = true; + } + // If the target is a folder, make sure we create it first + else if (isDirTarget && !SystemIO.IO_OS.DirectoryExists(targetpath)) + SystemIO.IO_OS.DirectoryCreate(targetpath); + + // Avoid setting restoring symlink metadata, as that writes the symlink target, not the symlink itself + if (!restoreSymlinkMetadata && Snapshots.SnapshotUtility.IsSymlink(SystemIO.IO_OS, targetpath)) + { + Logging.Log.WriteVerboseMessage(LOGTAG, "no-symlink-metadata-restored", "Not applying metadata to symlink: {0}", targetpath); + return wrote_something; + } + + if (metadata.TryGetValue("CoreLastWritetime", out k) && long.TryParse(k, out t)) + { + if (isDirTarget) + SystemIO.IO_OS.DirectorySetLastWriteTimeUtc(targetpath, new DateTime(t, DateTimeKind.Utc)); + else + SystemIO.IO_OS.FileSetLastWriteTimeUtc(targetpath, new DateTime(t, DateTimeKind.Utc)); + } + + if (metadata.TryGetValue("CoreCreatetime", out k) && long.TryParse(k, out t)) + { + if (isDirTarget) + SystemIO.IO_OS.DirectorySetCreationTimeUtc(targetpath, new DateTime(t, DateTimeKind.Utc)); + else + SystemIO.IO_OS.FileSetCreationTimeUtc(targetpath, new DateTime(t, DateTimeKind.Utc)); + } + + if (metadata.TryGetValue("CoreAttributes", out k) && Enum.TryParse(k, true, out fa)) + SystemIO.IO_OS.SetFileAttributes(targetpath, fa); + + SystemIO.IO_OS.SetMetadata(path, metadata, restorePermissions); + return wrote_something; + } + } + + private static async Task ScanForExistingSourceBlocksFast(LocalRestoreDatabase database, Options options, byte[] blockbuffer, System.Security.Cryptography.HashAlgorithm hasher, RestoreResults result) + { + // Fill BLOCKS with data from known local source files + using (var blockmarker = database.CreateBlockMarker()) + { + var updateCount = 0L; + foreach (var entry in database.GetFilesAndSourceBlocksFast(options.Blocksize)) + { + var targetpath = entry.TargetPath; + var targetfileid = entry.TargetFileID; + var sourcepath = entry.SourcePath; + var patched = false; + + try + { + if (SystemIO.IO_OS.FileExists(sourcepath)) + { + var folderpath = SystemIO.IO_OS.PathGetDirectoryName(targetpath); + if (!options.Dryrun && !SystemIO.IO_OS.DirectoryExists(folderpath)) + { + Logging.Log.WriteWarningMessage(LOGTAG, "CreateMissingFolder", null, "Creating missing folder {0} for file {1}", folderpath, targetpath); + SystemIO.IO_OS.DirectoryCreate(folderpath); + } + + using (var targetstream = options.Dryrun ? null : SystemIO.IO_OS.FileOpenWrite(targetpath)) + { + try + { + using (var sourcestream = SystemIO.IO_OS.FileOpenRead(sourcepath)) + { + foreach (var block in entry.Blocks) + { + if (!await result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) + return; + + //TODO: Handle metadata + + if (sourcestream.Length > block.Offset) + { + sourcestream.Position = block.Offset; + + int size = Library.Utility.Utility.ForceStreamRead(sourcestream, blockbuffer, blockbuffer.Length); + if (size == block.Size) + { + var key = Convert.ToBase64String(hasher.ComputeHash(blockbuffer, 0, size)); + if (key == block.Hash) + { + patched = true; + if (!options.Dryrun) + { + targetstream.Position = block.Offset; + targetstream.Write(blockbuffer, 0, size); + } + + blockmarker.SetBlockRestored(targetfileid, block.Index, key, block.Size, false); + } + } + } + } + } + } + catch (Exception ex) + { + Logging.Log.WriteWarningMessage(LOGTAG, "PatchingFileLocalFailed", ex, "Failed to patch file: \"{0}\" with data from local file \"{1}\", message: {2}", targetpath, sourcepath, ex.Message); + if (ex is System.Threading.ThreadAbortException) + throw; + } + } + + if ((++updateCount) % 20 == 0) + { + blockmarker.UpdateProcessed(result.OperationProgressUpdater); + if (!await result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) + return; + } + + } + else + { + Logging.Log.WriteVerboseMessage(LOGTAG, "LocalSourceMissing", "Local source file not found: {0}", sourcepath); + } + } + catch (Exception ex) + { + Logging.Log.WriteWarningMessage(LOGTAG, "PatchingFileLocalFailed", ex, "Failed to patch file: \"{0}\" with local data, message: {1}", targetpath, ex.Message); + if (ex is System.Threading.ThreadAbortException) + throw; + if (options.UnittestMode) + throw; + } + + if (patched) + Logging.Log.WriteVerboseMessage(LOGTAG, "FilePatchedWithLocal", "Target file is patched with some local data: {0}", targetpath); + else + Logging.Log.WriteVerboseMessage(LOGTAG, "FilePatchedWithLocal", "Target file is not patched any local data: {0}", targetpath); + + if (patched && options.Dryrun) + Logging.Log.WriteDryrunMessage(LOGTAG, "WouldPatchWithLocal", "Would patch file with local data: {0}", targetpath); + } + + blockmarker.UpdateProcessed(result.OperationProgressUpdater); + blockmarker.Commit(); + } + } + + private static async Task ScanForExistingSourceBlocks(LocalRestoreDatabase database, Options options, byte[] blockbuffer, System.Security.Cryptography.HashAlgorithm hasher, RestoreResults result, RestoreHandlerMetadataStorage metadatastorage) + { + // Fill BLOCKS with data from known local source files + using (var blockmarker = database.CreateBlockMarker()) + { + var updateCount = 0L; + foreach (var restorelist in database.GetFilesAndSourceBlocks(options.SkipMetadata, options.Blocksize)) + { + var targetpath = restorelist.TargetPath; + var targetfileid = restorelist.TargetFileID; + var patched = false; + try + { + if (!await result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) + return; + + var folderpath = SystemIO.IO_OS.PathGetDirectoryName(targetpath); + if (!options.Dryrun && !SystemIO.IO_OS.DirectoryExists(folderpath)) + { + Logging.Log.WriteWarningMessage(LOGTAG, "CreateMissingFolder", null, "Creating missing folder {0} for file {1}", folderpath, targetpath); + SystemIO.IO_OS.DirectoryCreate(folderpath); + } + + using (var file = options.Dryrun ? null : SystemIO.IO_OS.FileOpenWrite(targetpath)) + foreach (var targetblock in restorelist.Blocks) + { + foreach (var source in targetblock.Blocksources) + { + try + { + if (!await result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) + return; + + if (SystemIO.IO_OS.FileExists(source.Path)) + { + if (source.IsMetadata) + { + // TODO: Handle this by reconstructing + // metadata from file and checking the hash + + continue; + } + else + { + using (var sourcefile = SystemIO.IO_OS.FileOpenRead(source.Path)) + { + sourcefile.Position = source.Offset; + int size = Library.Utility.Utility.ForceStreamRead(sourcefile, blockbuffer, blockbuffer.Length); + if (size == targetblock.Size) + { + var key = Convert.ToBase64String(hasher.ComputeHash(blockbuffer, 0, size)); + if (key == targetblock.Hash) + { + if (!options.Dryrun) + { + if (targetblock.IsMetadata) + metadatastorage.Add(targetpath, new System.IO.MemoryStream(blockbuffer, 0, size)); + else + { + file.Position = targetblock.Offset; + file.Write(blockbuffer, 0, size); + } + } + + blockmarker.SetBlockRestored(targetfileid, targetblock.Index, key, targetblock.Size, false); + patched = true; + break; + } + } + } + } + } + } + catch (Exception ex) + { + Logging.Log.WriteWarningMessage(LOGTAG, "PatchingFileLocalFailed", ex, "Failed to patch file: \"{0}\" with data from local file \"{1}\", message: {2}", targetpath, source.Path, ex.Message); + if (ex is System.Threading.ThreadAbortException) + throw; + } + } + } + + if ((++updateCount) % 20 == 0) + blockmarker.UpdateProcessed(result.OperationProgressUpdater); + } + catch (Exception ex) + { + Logging.Log.WriteWarningMessage(LOGTAG, "PatchingFileLocalFailed", ex, "Failed to patch file: \"{0}\" with local data, message: {1}", targetpath, ex.Message); + if (options.UnittestMode) + throw; + } + + if (patched) + Logging.Log.WriteVerboseMessage(LOGTAG, "FilePatchedWithLocal", "Target file is patched with some local data: {0}", targetpath); + else + Logging.Log.WriteVerboseMessage(LOGTAG, "FilePatchedWithLocal", "Target file is not patched any local data: {0}", targetpath); + + if (patched && options.Dryrun) + Logging.Log.WriteDryrunMessage(LOGTAG, "WouldPatchWithLocal", string.Format("Would patch file with local data: {0}", targetpath)); + } + + blockmarker.UpdateProcessed(result.OperationProgressUpdater); + blockmarker.Commit(); + } + } + + private static void PrepareBlockAndFileList(LocalRestoreDatabase database, Options options, Library.Utility.IFilter filter, RestoreResults result) + { + // Create a temporary table FILES by selecting the files from fileset that matches a specific operation id + // Delete all entries from the temp table that are excluded by the filter(s) + using (new Logging.Timer(LOGTAG, "PrepareRestoreFileList", "PrepareRestoreFileList")) + { + var c = database.PrepareRestoreFilelist(options.Time, options.Version, filter); + result.OperationProgressUpdater.UpdatefileCount(c.Item1, c.Item2, true); + } + + using (new Logging.Timer(LOGTAG, "SetTargetPaths", "SetTargetPaths")) + if (!string.IsNullOrEmpty(options.Restorepath)) + { + // Find the largest common prefix + var largest_prefix = options.DontCompressRestorePaths ? "" : database.GetLargestPrefix(); + + Logging.Log.WriteVerboseMessage(LOGTAG, "MappingRestorePath", "Mapping restore path prefix to \"{0}\" to \"{1}\"", largest_prefix, Util.AppendDirSeparator(options.Restorepath)); + + // Set the target paths, special care with C:\ and / + database.SetTargetPaths(largest_prefix, Util.AppendDirSeparator(options.Restorepath)); + } + else + { + database.SetTargetPaths("", ""); + } + + // Create a temporary table BLOCKS that lists all blocks that needs to be recovered + using (new Logging.Timer(LOGTAG, "FindMissingBlocks", "FindMissingBlocks")) + database.FindMissingBlocks(options.SkipMetadata); + + // Create temporary tables and triggers that automatically track progress + using (new Logging.Timer(LOGTAG, "CreateProgressTracker", "CreateProgressTracker")) + database.CreateProgressTracker(false); + + } + + private static async Task CreateDirectoryStructure(LocalRestoreDatabase database, Options options, RestoreResults result) + { + // This part is not protected by try/catch as we need the target folder to exist + if (!string.IsNullOrEmpty(options.Restorepath)) + if (!SystemIO.IO_OS.DirectoryExists(options.Restorepath)) + { + Logging.Log.WriteVerboseMessage(LOGTAG, "CreateFolder", "Creating folder: {0}", options.Restorepath); + + if (options.Dryrun) + Logging.Log.WriteDryrunMessage(LOGTAG, "WouldCreateFolder", "Would create folder: {0}", options.Restorepath); + else + SystemIO.IO_OS.DirectoryCreate(options.Restorepath); + } + + foreach (var folder in database.GetTargetFolders()) + { + try + { + if (!await result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) + return; + + if (!SystemIO.IO_OS.DirectoryExists(folder)) + { + result.RestoredFolders++; + + Logging.Log.WriteVerboseMessage(LOGTAG, "CreateFolder", "Creating folder: {0}", folder); + + if (options.Dryrun) + Logging.Log.WriteDryrunMessage(LOGTAG, "WouldCreateFolder", "Would create folder: {0}", folder); + else + SystemIO.IO_OS.DirectoryCreate(folder); + } + } + catch (Exception ex) + { + Logging.Log.WriteWarningMessage(LOGTAG, "FolderCreateFailed", ex, "Failed to create folder: \"{0}\", message: {1}", folder, ex.Message); + if (options.UnittestMode) + throw; + } + } + } + + private static async Task ScanForExistingTargetBlocks(LocalRestoreDatabase database, byte[] blockbuffer, System.Security.Cryptography.HashAlgorithm blockhasher, System.Security.Cryptography.HashAlgorithm filehasher, Options options, RestoreResults result) + { + // Scan existing files for existing BLOCKS + using (var blockmarker = database.CreateBlockMarker()) + { + var updateCount = 0L; + foreach (var restorelist in database.GetExistingFilesWithBlocks()) + { + var rename = !options.Overwrite; + var targetpath = restorelist.TargetPath; + var targetfileid = restorelist.TargetFileID; + var targetfilehash = restorelist.TargetHash; + var targetfilelength = restorelist.Length; + if (SystemIO.IO_OS.FileExists(targetpath)) + { + try + { + if (!await result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) + return; + + var currentfilelength = SystemIO.IO_OS.FileLength(targetpath); + var wasTruncated = false; + + // Adjust file length in overwrite mode if necessary (smaller is ok, will be extended during restore) + // We do it before scanning for blocks. This allows full verification on files that only needs to + // be truncated (i.e. forthwritten log files). + if (!rename && currentfilelength > targetfilelength) + { + var currentAttr = SystemIO.IO_OS.GetFileAttributes(targetpath); + if ((currentAttr & System.IO.FileAttributes.ReadOnly) != 0) // clear readonly attribute + { + if (options.Dryrun) + Logging.Log.WriteDryrunMessage(LOGTAG, "WouldResetReadOnlyAttribute", "Would reset read-only attribute on file: {0}", targetpath); + else SystemIO.IO_OS.SetFileAttributes(targetpath, currentAttr & ~System.IO.FileAttributes.ReadOnly); + } + if (options.Dryrun) + Logging.Log.WriteDryrunMessage(LOGTAG, "WouldTruncateFile", "Would truncate file '{0}' to length of {1:N0} bytes", targetpath, targetfilelength); + else + { + using (var file = SystemIO.IO_OS.FileOpenWrite(targetpath)) + file.SetLength(targetfilelength); + currentfilelength = targetfilelength; + } + wasTruncated = true; + } + + // If file size does not match and we have to rename on conflict, + // the whole scan can be skipped here because all blocks have to be restored anyway. + // For the other cases, we will check block and and file hashes and look for blocks + // to be restored and files that can already be verified. + if (!rename || currentfilelength == targetfilelength) + { + // a file hash for verification will only be necessary if the file has exactly + // the wanted size so we have a chance to already mark the file as data-verified. + bool calcFileHash = (currentfilelength == targetfilelength); + if (calcFileHash) filehasher.Initialize(); + + using (var file = SystemIO.IO_OS.FileOpenRead(targetpath)) + using (var block = new Blockprocessor(file, blockbuffer)) + foreach (var targetblock in restorelist.Blocks) + { + var size = block.Readblock(); + if (size <= 0) + break; + + //TODO: Handle Metadata + + bool blockhashmatch = false; + if (size == targetblock.Size) + { + // Parallelize file hash calculation on rename. Running read-only on same array should not cause conflicts or races. + // Actually, in future always calculate the file hash and mark the file data as already verified. + + System.Threading.Tasks.Task calcFileHashTask = null; + if (calcFileHash) + calcFileHashTask = System.Threading.Tasks.Task.Run( + () => filehasher.TransformBlock(blockbuffer, 0, size, blockbuffer, 0)); + + var key = Convert.ToBase64String(blockhasher.ComputeHash(blockbuffer, 0, size)); + + if (calcFileHashTask != null) await calcFileHashTask.ConfigureAwait(false); // wait because blockbuffer will be overwritten. + + if (key == targetblock.Hash) + { + blockmarker.SetBlockRestored(targetfileid, targetblock.Index, key, size, false); + blockhashmatch = true; + } + } + if (calcFileHash && !blockhashmatch) // will not be necessary anymore + { + filehasher.TransformFinalBlock(blockbuffer, 0, 0); // So a new initialize will not throw + calcFileHash = false; + if (rename) // file does not match. So break. + break; + } + } + + bool fullfilehashmatch = false; + if (calcFileHash) // now check if files are identical + { + filehasher.TransformFinalBlock(blockbuffer, 0, 0); + var filekey = Convert.ToBase64String(filehasher.Hash); + fullfilehashmatch = (filekey == targetfilehash); + } + + if (!rename && !fullfilehashmatch && !wasTruncated) // Reset read-only attribute (if set) to overwrite + { + var currentAttr = SystemIO.IO_OS.GetFileAttributes(targetpath); + if ((currentAttr & System.IO.FileAttributes.ReadOnly) != 0) + { + if (options.Dryrun) + Logging.Log.WriteDryrunMessage(LOGTAG, "WouldResetReadOnlyAttribyte", "Would reset read-only attribute on file: {0}", targetpath); + else SystemIO.IO_OS.SetFileAttributes(targetpath, currentAttr & ~System.IO.FileAttributes.ReadOnly); + } + } + + if (fullfilehashmatch) + { + //TODO: Check metadata to trigger rename? If metadata changed, it will still be restored for the file in-place. + blockmarker.SetFileDataVerified(targetfileid); + Logging.Log.WriteVerboseMessage(LOGTAG, "TargetExistsInCorrectVersion", "Target file exists{1} and is correct version: {0}", targetpath, wasTruncated ? " (but was truncated)" : ""); + rename = false; + } + else if (rename) + { + // The new file will have none of the correct blocks, + // even if the scanned file had some + blockmarker.SetAllBlocksMissing(targetfileid); + } + } + + if ((++updateCount) % 20 == 0) + { + blockmarker.UpdateProcessed(result.OperationProgressUpdater); + if (!await result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) + return; + } + } + catch (Exception ex) + { + Logging.Log.WriteWarningMessage(LOGTAG, "TargetFileReadError", ex, "Failed to read target file: \"{0}\", message: {1}", targetpath, ex.Message); + if (ex is System.Threading.ThreadAbortException) + throw; + if (options.UnittestMode) + throw; + } + } + else + { + Logging.Log.WriteVerboseMessage(LOGTAG, "MissingTargetFile", "Target file does not exist: {0}", targetpath); + rename = false; + } + + if (rename) + { + //Select a new filename + var ext = SystemIO.IO_OS.PathGetExtension(targetpath) ?? ""; + if (!string.IsNullOrEmpty(ext) && !ext.StartsWith(".", StringComparison.Ordinal)) + ext = "." + ext; + + // First we try with a simple date append, assuming that there are not many conflicts there + var newname = SystemIO.IO_OS.PathChangeExtension(targetpath, null) + "." + database.RestoreTime.ToLocalTime().ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture); + var tr = newname + ext; + var c = 0; + while (SystemIO.IO_OS.FileExists(tr) && c < 1000) + { + try + { + // If we have a file with the correct name, + // it is most likely the file we want + filehasher.Initialize(); + + string key; + using (var file = SystemIO.IO_OS.FileOpenRead(tr)) + key = Convert.ToBase64String(filehasher.ComputeHash(file)); + + if (key == targetfilehash) + { + //TODO: Also needs metadata check to make correct decision. + // We stick to the policy to restore metadata in place, if data ok. So, metadata block may be restored. + blockmarker.SetAllBlocksRestored(targetfileid, false); + blockmarker.SetFileDataVerified(targetfileid); + break; + } + } + catch (Exception ex) + { + Logging.Log.WriteWarningMessage(LOGTAG, "FailedToReadRestoreTarget", ex, "Failed to read candidate restore target {0}", tr); + if (options.UnittestMode) + throw; + } + tr = newname + " (" + (c++).ToString() + ")" + ext; + } + + newname = tr; + + Logging.Log.WriteVerboseMessage(LOGTAG, "TargetFileRetargeted", "Target file exists and will be restored to: {0}", newname); + database.UpdateTargetPath(targetfileid, newname); + } + + } + + blockmarker.UpdateProcessed(result.OperationProgressUpdater); + blockmarker.Commit(); + } + } + } +} diff --git a/Duplicati/Library/Main/Operation/SystemInfoHandler.cs b/Duplicati/Library/Main/Operation/SystemInfoHandler.cs index 3c1f4579c..07071de20 100644 --- a/Duplicati/Library/Main/Operation/SystemInfoHandler.cs +++ b/Duplicati/Library/Main/Operation/SystemInfoHandler.cs @@ -1,50 +1,49 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; using System.Linq; using System.Globalization; +using System.Threading.Tasks; namespace Duplicati.Library.Main.Operation { internal static class SystemInfoHandler { - public static void Run(SystemInfoResults results) - { - results.Lines = GetSystemInfo().ToArray(); - } + public static Task RunAsync(SystemInfoResults results) + => Task.Run(() => results.Lines = GetSystemInfo().ToArray()); public static IEnumerable GetSystemInfo() { yield return string.Format("Duplicati: {0} ({1})", Library.Utility.Utility.getEntryAssembly().FullName, System.Reflection.Assembly.GetExecutingAssembly().FullName); yield return string.Format("Autoupdate urls: {0}", string.Join(";", AutoUpdater.AutoUpdateSettings.URLs)); - yield return string.Format("Update folder: {0}", AutoUpdater.UpdaterManager.UPDATEDIR); + yield return string.Format("Default data folder: {0}", AutoUpdater.DataFolderManager.DATAFOLDER); yield return string.Format("Install folder: {0}", AutoUpdater.UpdaterManager.INSTALLATIONDIR); yield return string.Format("Version name: \"{0}\" ({1})", AutoUpdater.UpdaterManager.SelfVersion.Displayname, System.Reflection.Assembly.GetExecutingAssembly().GetName().Version); yield return string.Format("Current Version folder {0}", System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location)); yield return string.Format("OS: {0}", Library.UsageReporter.OSInfoHelper.PlatformString); - yield return string.Format("OSType: {0}", OperatingSystem.IsWindows() ? "Windows" : OperatingSystem.IsLinux() ? "Linux" : OperatingSystem.IsMacOS() ? "MacOS" : "Unknown"); + yield return string.Format("OSType: {0}", AutoUpdater.UpdaterManager.OperatingSystemName); yield return string.Format("64bit: {0} ({1})", Environment.Is64BitOperatingSystem, Environment.Is64BitProcess); yield return string.Format("Machinename: {0}", Environment.MachineName); diff --git a/Duplicati/Library/Main/Operation/TestFilterHandler.cs b/Duplicati/Library/Main/Operation/TestFilterHandler.cs index c2f96ffba..b00d37f7a 100644 --- a/Duplicati/Library/Main/Operation/TestFilterHandler.cs +++ b/Duplicati/Library/Main/Operation/TestFilterHandler.cs @@ -1,27 +1,28 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +#nullable enable + using System; using System.IO; -using Duplicati.Library.Snapshots; using CoCoL; using System.Threading.Tasks; @@ -45,14 +46,13 @@ namespace Duplicati.Library.Main.Operation public async Task RunAsync(string[] sources, Library.Utility.IFilter filter) { - var sourcefilter = new Library.Utility.FilterExpression(sources, true); var stopToken = m_result.TaskControl.ProgressToken; - using (var snapshot = BackupHandler.GetSnapshot(sources, m_options)) + using (var provider = await BackupHandler.GetSourceProvider(sources, m_options, stopToken).ConfigureAwait(false)) { Backup.Channels channels = new(); - var source = Backup.FileEnumerationProcess.Run(channels, sources, snapshot, null, - m_options.FileAttributeFilter, sourcefilter, filter, m_options.SymlinkPolicy, + var source = Backup.FileEnumerationProcess.Run(channels, provider, null, + m_options.FileAttributeFilter, filter, m_options.SymlinkPolicy, m_options.HardlinkPolicy, m_options.ExcludeEmptyFolders, m_options.IgnoreFilenames, BackupHandler.GetBlacklistedPaths(m_options), null, m_result.TaskControl, null, stopToken); @@ -64,20 +64,20 @@ namespace Duplicati.Library.Main.Operation { while (await m_result.TaskControl.ProgressRendevouz().ConfigureAwait(false)) { - var path = await self.source.ReadAsync(); - var fa = FileAttributes.Normal; - try { fa = snapshot.GetAttributes(path); } - catch (Exception ex) { Logging.Log.WriteVerboseMessage(LOGTAG, "FailedAttributeRead", "Failed to read attributes from {0}: {1}", path, ex.Message); } + var entry = await self.source.ReadAsync(); + var fa = entry.IsFolder + ? FileAttributes.Directory + : FileAttributes.Normal; + + try { fa = entry.Attributes; } + catch (Exception ex) { Logging.Log.WriteVerboseMessage(LOGTAG, "FailedAttributeRead", "Failed to read attributes from {0}: {1}", entry.Path, ex.Message); } // Analyze symlinks - var isSymlink = snapshot.IsSymlink(path, fa); - string symlinkTarget = null; + string? symlinkTarget = null; + try { symlinkTarget = entry.SymlinkTarget; } + catch (Exception ex) { Logging.Log.WriteExplicitMessage(LOGTAG, "SymlinkTargetReadFailure", ex, "Failed to read symlink target for path: {0}", entry.Path); } - if (isSymlink) - try { symlinkTarget = snapshot.GetSymlinkTarget(path); } - catch (Exception ex) { Logging.Log.WriteExplicitMessage(LOGTAG, "SymlinkTargetReadFailure", ex, "Failed to read symlink target for path: {0}", path); } - - if (isSymlink && m_options.SymlinkPolicy == Options.SymlinkStrategy.Store && !string.IsNullOrWhiteSpace(symlinkTarget)) + if (m_options.SymlinkPolicy == Options.SymlinkStrategy.Store && !string.IsNullOrWhiteSpace(symlinkTarget)) { // Skip stored symlinks continue; @@ -86,33 +86,44 @@ namespace Duplicati.Library.Main.Operation // Go for the symlink target, as we know we follow symlinks if (!string.IsNullOrWhiteSpace(symlinkTarget)) { - path = symlinkTarget; + var targetEntry = await provider.GetEntry(symlinkTarget, false, stopToken).ConfigureAwait(false); fa = FileAttributes.Normal; - try { fa = snapshot.GetAttributes(path); } - catch (Exception ex) { Logging.Log.WriteVerboseMessage(LOGTAG, "FailedAttributeRead", "Failed to read attributes from {0}: {1}", path, ex.Message); } + + try { fa = targetEntry!.Attributes; } + catch (Exception ex) { Logging.Log.WriteVerboseMessage(LOGTAG, "FailedAttributeRead", "Failed to read attributes from {0}: {1}", entry.Path, ex.Message); } + + // If we guessed wrong and the symlink target is a folder, we need to fetch it with the correct flag + if (fa.HasFlag(FileAttributes.Directory)) + targetEntry = await provider.GetEntry(symlinkTarget, true, stopToken).ConfigureAwait(false); + + // No such target + if (targetEntry == null) + { + Logging.Log.WriteVerboseMessage(LOGTAG, "SymlinkTargetNotFound", "Symlink target not found: {0}", symlinkTarget); + continue; + } } // Proceed with non-folders - if (!((fa & FileAttributes.Directory) == FileAttributes.Directory)) + if (!entry.IsFolder) { m_result.FileCount++; var size = -1L; try { - size = snapshot.GetFileSize(path); + size = entry.Size; m_result.FileSize += size; } catch (Exception ex) { - Logging.Log.WriteVerboseMessage(LOGTAG, "SizeReadFailed", "Failed to read length of file {0}: {1}", path, ex.Message); + Logging.Log.WriteVerboseMessage(LOGTAG, "SizeReadFailed", "Failed to read length of file {0}: {1}", entry.Path, ex.Message); } - if (m_options.SkipFilesLargerThan == long.MaxValue || m_options.SkipFilesLargerThan == 0 || size < m_options.SkipFilesLargerThan) - Logging.Log.WriteVerboseMessage(LOGTAG, "IncludeFile", "Including file: {0} ({1})", path, size < 0 ? "unknown" : Duplicati.Library.Utility.Utility.FormatSizeString(size)); + Logging.Log.WriteVerboseMessage(LOGTAG, "IncludeFile", "Including file: {0} ({1})", entry.Path, size < 0 ? "unknown" : Duplicati.Library.Utility.Utility.FormatSizeString(size)); else - Logging.Log.WriteVerboseMessage(LOGTAG, "ExcludeLargeFile", "Excluding file due to size: {0} ({1})", path, size < 0 ? "unknown" : Duplicati.Library.Utility.Utility.FormatSizeString(size)); + Logging.Log.WriteVerboseMessage(LOGTAG, "ExcludeLargeFile", "Excluding file due to size: {0} ({1})", entry.Path, size < 0 ? "unknown" : Duplicati.Library.Utility.Utility.FormatSizeString(size)); } } } diff --git a/Duplicati/Library/Main/Operation/TestHandler.cs b/Duplicati/Library/Main/Operation/TestHandler.cs index 21f485cf3..747e1c8ef 100644 --- a/Duplicati/Library/Main/Operation/TestHandler.cs +++ b/Duplicati/Library/Main/Operation/TestHandler.cs @@ -46,7 +46,7 @@ namespace Duplicati.Library.Main.Operation m_results = results; } - public void Run(long samples, IBackendManager backendManager) + public async Task RunAsync(long samples, IBackendManager backendManager) { if (!System.IO.File.Exists(m_options.Dbpath)) throw new UserInformationException(string.Format("Database file does not exist: {0}", m_options.Dbpath), "DatabaseDoesNotExist"); @@ -57,14 +57,14 @@ namespace Duplicati.Library.Main.Operation Utility.UpdateOptionsFromDb(db, m_options); Utility.VerifyOptionsAndUpdateDatabase(db, m_options); db.VerifyConsistency(m_options.Blocksize, m_options.BlockhashSize, !m_options.DisableFilelistConsistencyChecks, null); - FilelistProcessor.VerifyRemoteList(backendManager, m_options, db, m_results.BackendWriter, true, null).Await(); + await FilelistProcessor.VerifyRemoteList(backendManager, m_options, db, m_results.BackendWriter, latestVolumesOnly: true, verifyMode: FilelistProcessor.VerifyMode.VerifyOnly, null).ConfigureAwait(false); - DoRun(samples, db, backendManager).Await(); + await DoRunAsync(samples, db, backendManager).ConfigureAwait(false); db.WriteResults(); } } - public async Task DoRun(long samples, LocalTestDatabase db, IBackendManager backend) + public async Task DoRunAsync(long samples, LocalTestDatabase db, IBackendManager backend) { var files = db.SelectTestTargets(samples, m_options).ToList(); @@ -74,14 +74,14 @@ namespace Duplicati.Library.Main.Operation if (m_options.FullRemoteVerification != Options.RemoteTestStrategy.False) { - await foreach (var (tf, hash, size, name) in backend.GetFilesOverlappedAsync(files, CancellationToken.None)) + await foreach (var (tf, hash, size, name) in backend.GetFilesOverlappedAsync(files, m_results.TaskControl.ProgressToken).ConfigureAwait(false)) { var vol = new RemoteVolume(name, hash, size); try { - if (!m_results.TaskControl.ProgressRendevouz().Await()) + if (!await m_results.TaskControl.ProgressRendevouz().ConfigureAwait(false)) { - backend.WaitForEmptyAsync(db, null, CancellationToken.None).Await(); + await backend.WaitForEmptyAsync(db, null, m_results.TaskControl.ProgressToken).ConfigureAwait(false); m_results.EndTime = DateTime.UtcNow; return; } @@ -138,7 +138,7 @@ namespace Duplicati.Library.Main.Operation { try { - if (!m_results.TaskControl.ProgressRendevouz().Await()) + if (!await m_results.TaskControl.ProgressRendevouz().ConfigureAwait(false)) { m_results.EndTime = DateTime.UtcNow; return; @@ -152,7 +152,7 @@ namespace Duplicati.Library.Main.Operation Logging.Log.WriteInformationMessage(LOGTAG, "MissingRemoteHash", "No hash or size recorded for {0}, performing full verification", f.Name); KeyValuePair>> res; - (var tf, var hash, var size) = backend.GetWithInfoAsync(f.Name, f.Hash, f.Size, CancellationToken.None).Await(); + (var tf, var hash, var size) = await backend.GetWithInfoAsync(f.Name, f.Hash, f.Size, m_results.TaskControl.ProgressToken).ConfigureAwait(false); using (tf) res = TestVolumeInternals(db, f, tf, m_options, 1); @@ -176,7 +176,7 @@ namespace Duplicati.Library.Main.Operation } else { - using (var tf = backend.GetAsync(f.Name, f.Hash, f.Size, CancellationToken.None).Await()) + using (var tf = await backend.GetAsync(f.Name, f.Hash, f.Size, m_results.TaskControl.ProgressToken).ConfigureAwait(false)) { } } diff --git a/Duplicati/Library/Main/Operation/VacuumHandler.cs b/Duplicati/Library/Main/Operation/VacuumHandler.cs index c2e873d47..cb324129a 100644 --- a/Duplicati/Library/Main/Operation/VacuumHandler.cs +++ b/Duplicati/Library/Main/Operation/VacuumHandler.cs @@ -1,23 +1,23 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; using System.Linq; @@ -37,15 +37,16 @@ namespace Duplicati.Library.Main.Operation m_result = result; } - public virtual void Run() - { - using (var db = new Database.LocalDatabase(m_options.Dbpath, "Vacuum", false)) + public virtual Task RunAsync() + => Task.Run(() => { - m_result.SetDatabase(db); - m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Vacuum_Running); - db.Vacuum(); - m_result.EndTime = DateTime.UtcNow; - } - } + using (var db = new Database.LocalDatabase(m_options.Dbpath, "Vacuum", false)) + { + m_result.SetDatabase(db); + m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Vacuum_Running); + db.Vacuum(); + m_result.EndTime = DateTime.UtcNow; + } + }); } } diff --git a/Duplicati/Library/Main/Options.cs b/Duplicati/Library/Main/Options.cs index 6131d1dd1..ea7ba72e2 100644 --- a/Duplicati/Library/Main/Options.cs +++ b/Duplicati/Library/Main/Options.cs @@ -1,22 +1,22 @@ // Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the +// +// 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 +// +// 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 +// +// 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; @@ -129,6 +129,11 @@ namespace Duplicati.Library.Main /// private readonly int DEFAULT_RESTORE_VOLUME_DOWNLOADERS = Math.Max(1, Environment.ProcessorCount / 2); + /// + /// The default value for the size of the channel buffers during restore + /// + private readonly int DEFAULT_RESTORE_CHANNEL_BUFFER_SIZE = 8; + /// /// An enumeration that describes the supported strategies for an optimization /// @@ -345,6 +350,7 @@ namespace Duplicati.Library.Main new CommandLineArgument("upload-unchanged-backups", CommandLineArgument.ArgumentType.Boolean, Strings.Options.UploadUnchangedBackupsShort, Strings.Options.UploadUnchangedBackupsLong, "false"), new CommandLineArgument("snapshot-policy", CommandLineArgument.ArgumentType.Enumeration, Strings.Options.SnapshotpolicyShort, Strings.Options.SnapshotpolicyLong, "off", null, Enum.GetNames(typeof(OptimizationStrategy))), + new CommandLineArgument("snapshot-provider", CommandLineArgument.ArgumentType.Enumeration, Strings.Options.SnapshotproviderShort, Strings.Options.SnapshotproviderLong, OperatingSystem.IsWindows() ? Snapshots.SnapshotProvider.AlphaVSS.ToString() : Snapshots.SnapshotProvider.LVM.ToString(), null, (OperatingSystem.IsWindows() ? [Snapshots.SnapshotProvider.AlphaVSS, Snapshots.SnapshotProvider.Wmic] : new [] { Snapshots.SnapshotProvider.LVM }).Select(x => x.ToString()).ToArray()), new CommandLineArgument("vss-exclude-writers", CommandLineArgument.ArgumentType.String, Strings.Options.VssexcludewritersShort, Strings.Options.VssexcludewritersLong, "{e8132975-6f93-4464-a53e-1050253ae220}"), new CommandLineArgument("vss-use-mapping", CommandLineArgument.ArgumentType.Boolean, Strings.Options.VssusemappingShort, Strings.Options.VssusemappingLong, "false"), new CommandLineArgument("usn-policy", CommandLineArgument.ArgumentType.Enumeration, Strings.Options.UsnpolicyShort, Strings.Options.UsnpolicyLong, "off", null, Enum.GetNames(typeof(OptimizationStrategy))), @@ -384,8 +390,8 @@ namespace Duplicati.Library.Main new CommandLineArgument("exclude-files-attributes", CommandLineArgument.ArgumentType.String, Strings.Options.ExcludefilesattributesShort, Strings.Options.ExcludefilesattributesLong(Enum.GetNames(typeof(System.IO.FileAttributes)))), new CommandLineArgument("compression-extension-file", CommandLineArgument.ArgumentType.Path, Strings.Options.CompressionextensionfileShort, Strings.Options.CompressionextensionfileLong(DEFAULT_COMPRESSED_EXTENSION_FILE), DEFAULT_COMPRESSED_EXTENSION_FILE), - new CommandLineArgument("machine-id", CommandLineArgument.ArgumentType.String, Strings.Options.MachineidShort, Strings.Options.MachineidLong, Library.AutoUpdater.UpdaterManager.InstallID), - new CommandLineArgument("machine-name", CommandLineArgument.ArgumentType.String, Strings.Options.MachinenameShort, Strings.Options.MachinenameLong, Library.AutoUpdater.UpdaterManager.MachineName), + new CommandLineArgument("machine-id", CommandLineArgument.ArgumentType.String, Strings.Options.MachineidShort, Strings.Options.MachineidLong, Library.AutoUpdater.DataFolderManager.InstallID), + new CommandLineArgument("machine-name", CommandLineArgument.ArgumentType.String, Strings.Options.MachinenameShort, Strings.Options.MachinenameLong, Library.AutoUpdater.DataFolderManager.MachineName), new CommandLineArgument("backup-id", CommandLineArgument.ArgumentType.String, Strings.Options.BackupidShort, Strings.Options.BackupidLong, ""), new CommandLineArgument("backup-name", CommandLineArgument.ArgumentType.String, Strings.Options.BackupnameShort, Strings.Options.BackupnameLong, DefaultBackupName), new CommandLineArgument("next-scheduled-run", CommandLineArgument.ArgumentType.String, Strings.Options.NextscheduledrunShort, Strings.Options.NextscheduledrunLong), @@ -440,6 +446,7 @@ namespace Duplicati.Library.Main new CommandLineArgument("repair-only-paths", CommandLineArgument.ArgumentType.Boolean, Strings.Options.RepaironlypathsShort, Strings.Options.RepaironlypathsLong, "false"), new CommandLineArgument("repair-force-block-use", CommandLineArgument.ArgumentType.Boolean, Strings.Options.RepaironlypathsShort, Strings.Options.RepaironlypathsLong, "false"), + new CommandLineArgument("repair-ignore-outdated-database", CommandLineArgument.ArgumentType.Boolean, Strings.Options.RepairignoreoutdateddatabaseShort, Strings.Options.RepairignoreoutdateddatabaseLong, "false"), new CommandLineArgument("force-locale", CommandLineArgument.ArgumentType.String, Strings.Options.ForcelocaleShort, Strings.Options.ForcelocaleLong), new CommandLineArgument("force-actual-date", CommandLineArgument.ArgumentType.Boolean, Strings.Options.ForceActualDateShort, Strings.Options.ForceActualDateLong, "false"), @@ -474,6 +481,7 @@ namespace Duplicati.Library.Main new CommandLineArgument("restore-volume-decompressors", CommandLineArgument.ArgumentType.Integer, Strings.Options.RestoreVolumeDecompressorsShort, Strings.Options.RestoreVolumeDecompressorsLong, DEFAULT_RESTORE_VOLUME_DECOMPRESSORS.ToString()), new CommandLineArgument("restore-volume-decryptors", CommandLineArgument.ArgumentType.Integer, Strings.Options.RestoreVolumeDecryptorsShort, Strings.Options.RestoreVolumeDecryptorsLong, DEFAULT_RESTORE_VOLUME_DECRYPTORS.ToString()), new CommandLineArgument("restore-volume-downloaders", CommandLineArgument.ArgumentType.Integer, Strings.Options.RestoreVolumeDownloadersShort, Strings.Options.RestoreVolumeDownloadersLong, DEFAULT_RESTORE_VOLUME_DOWNLOADERS.ToString()), + new CommandLineArgument("restore-channel-buffer-size", CommandLineArgument.ArgumentType.Integer, Strings.Options.RestoreChannelBufferSizeShort, Strings.Options.RestoreChannelBufferSizeLong, DEFAULT_RESTORE_CHANNEL_BUFFER_SIZE.ToString()), new CommandLineArgument("internal-profiling", CommandLineArgument.ArgumentType.Boolean, Strings.Options.InternalProfilingShort, Strings.Options.InternalProfilingLong, "false"), }); @@ -1025,6 +1033,24 @@ namespace Duplicati.Library.Main } } + /// + /// Gets the snapshot strategy to use + /// + public Snapshots.SnapshotProvider SnapShotProvider + { + get + { + if (!m_options.TryGetValue("snapshot-provider", out var provider)) + provider = ""; + + Snapshots.SnapshotProvider r; + if (!Enum.TryParse(provider, true, out r)) + r = OperatingSystem.IsWindows() ? Snapshots.SnapshotProvider.AlphaVSS : Snapshots.SnapshotProvider.LVM; + + return r; + } + } + /// /// Gets a flag indicating if advisory locking should be ignored /// @@ -1367,7 +1393,7 @@ namespace Duplicati.Library.Main { if (m_options.TryGetValue("machine-id", out var tmp)) return tmp; - return Library.AutoUpdater.UpdaterManager.InstallID; + return Library.AutoUpdater.DataFolderManager.InstallID; } } /// @@ -1687,7 +1713,7 @@ namespace Duplicati.Library.Main } /// - /// Gets a flag indicating if compacting should not be done automatically + /// Gets a flag indicating if missing source elements should be ignored /// public bool AllowMissingSource { @@ -1820,6 +1846,15 @@ namespace Duplicati.Library.Main get { return Library.Utility.Utility.ParseBoolOption(m_options, "repair-only-paths"); } } + /// + /// Gets a flag indicating if the repair process will ignore outdated database + /// + /// true if repair process will ignore outdated database; otherwise, false. + public bool RepairIgnoreOutdatedDatabase + { + get { return Library.Utility.Utility.ParseBoolOption(m_options, "repair-ignore-outdated-database"); } + } + /// /// Gets a flag indicating if the repair process will always use blocks /// @@ -2057,7 +2092,7 @@ namespace Duplicati.Library.Main } /// - /// Gets the maximum number of data blocks to keep in the cache. If set to 0, the cache is disabled. + /// Gets the maximum number of data blocks to keep in the cache. If set to 0, the cache is effictively disabled, but some is still kept for bookkeeping. /// public long RestoreCacheMax { @@ -2071,7 +2106,7 @@ namespace Duplicati.Library.Main if (max_cache > 0 && max_cache < Blocksize) throw new ArgumentOutOfRangeException(nameof(max_cache), string.Format("The maximum cache size cannot be less than the blocksize if not explicitly 0: {0} < {1}", max_cache, Blocksize)); - return max_cache; + return max_cache / Blocksize; } } @@ -2191,6 +2226,20 @@ namespace Duplicati.Library.Main } } + public int RestoreChannelBufferSize + { + get + { + if (!m_options.TryGetValue("restore-channel-buffer-size", out string v)) + v = null; + + if (string.IsNullOrEmpty(v)) + return DEFAULT_RESTORE_CHANNEL_BUFFER_SIZE; + else + return int.Parse(v); + } + } + /// /// Toggles whether internal profiling is enabled and should be logged. /// diff --git a/Duplicati/Library/Main/ResultClasses.cs b/Duplicati/Library/Main/ResultClasses.cs index 0765ed458..25123a65d 100644 --- a/Duplicati/Library/Main/ResultClasses.cs +++ b/Duplicati/Library/Main/ResultClasses.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -99,6 +99,7 @@ namespace Duplicati.Library.Main public long UnknownFileCount { get; set; } public long KnownFileCount { get; set; } public long KnownFileSize { get; set; } + public long KnownFilesets { get; set; } public DateTime LastBackupDate { get; set; } public long BackupListCount { get; set; } public long TotalQuotaSpace { get; set; } @@ -449,6 +450,7 @@ namespace Duplicati.Library.Main public long AddedFolders { get; internal set; } public long TooLargeFiles { get; internal set; } public long FilesWithError { get; internal set; } + public long TimestampChangedFiles { get; internal set; } public long ModifiedFolders { get; internal set; } public long ModifiedSymlinks { get; internal set; } public long AddedSymlinks { get; internal set; } diff --git a/Duplicati/Library/Main/Strings.cs b/Duplicati/Library/Main/Strings.cs index 6ee298520..c72177a5d 100644 --- a/Duplicati/Library/Main/Strings.cs +++ b/Duplicati/Library/Main/Strings.cs @@ -1,22 +1,22 @@ // Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the +// +// 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 +// +// 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 +// +// 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 Duplicati.Library.Localization.Short; @@ -116,6 +116,8 @@ namespace Duplicati.Library.Main.Strings public static string EnablemoduleShort { get { return LC.L(@"Enable one or more modules"); } } public static string SnapshotpolicyLong { get { return LC.L(@"This setting controls the usage of snapshots, which allows Duplicati to backup files that are locked by other programs. If this is set to ""off"", Duplicati will not attempt to create a disk snapshot. Setting this to ""auto"" makes Duplicati attempt to create a snapshot, and fail silently if that was not allowed or supported (note that the OS may still log system warnings). A setting of ""on"" will also make Duplicati attempt to create a snapshot, but will produce a warning message in the log if it fails. Setting it to ""required"" will make Duplicati abort the backup if the snapshot creation fails. On windows this uses the Volume Shadow Copy Services (VSS) and requires administrative privileges. On Linux this uses Logical Volume Management (LVM) and requires root privileges."); } } public static string SnapshotpolicyShort { get { return LC.L(@"Control the use of disk snapshots"); } } + public static string SnapshotproviderLong { get { return LC.L(@"The snapshot provider implementation for Windows. The AlphaVSS is the most feature complete, but is not supported on Arm64. The WMIC based snapshot has less features, but is more portable. On Linux, only LVM is supported"); } } + public static string SnapshotproviderShort { get { return LC.L(@"The snapshot provider implementation to use"); } } public static string AsynchronousuploadfolderLong { get { return LC.L(@"The pre-generated volumes will be placed into the temporary folder by default. This option can set a different folder for placing the temporary volumes. Despite the name, this also works for synchronous runs."); } } public static string AsynchronousuploadfolderShort { get { return LC.L(@"The path where ready volumes are placed until uploaded"); } } public static string AsynchronousuploadlimitLong { get { return LC.L(@"When performing asynchronous uploads, Duplicati will create volumes that can be uploaded. To prevent Duplicati from generating too many volumes, this option limits the number of pending uploads. Set to zero to disable the limit. The volume(s) that are being created are not counted in this limit. Use the option --concurrency-compressors=1 to limit the number of volumes being created."); } } @@ -228,7 +230,7 @@ namespace Duplicati.Library.Main.Strings public static string UploadverificationfileShort { get { return LC.L(@"Determine if verification files are uploaded"); } } public static string BackendtestsamplesLong(string optionname) { return LC.L(@"After a backup is completed, some (dblock, dindex, dlist) files from the remote backend are selected for verification. Use this option to change how many. If the option --{0} is also provided, the number of samples tested is the maximum implied by the two options. If this value is set to 0 or the option --{1} is set, no remote files are verified.", "backup-test-percentage", optionname); } public static string BackendtestsamplesShort { get { return LC.L(@"The number of samples to test after a backup"); } } - public static string BackendtestpercentageLong { get { return LC.L(@"After a backup is completed, some (dblock, dindex, dlist) files from the remote backend are selected for verification. Use this option to specify the percentage (between 0 and 100) of files to test. If the option --{0} is also provided, the number of samples tested is the maximum implied by the two options. If the option --{1} is provided, no remote files are verified.", "backup-test-samples", "no-backend-verification"); } } + public static string BackendtestpercentageLong { get { return LC.L(@"After a backup is completed, some samples (one sample is 1 dblock, 1 dindex, and 1 dlist) files from the remote backend are selected for verification. Use this option to specify the percentage (between 0 and 100) of samples to test. If the option --{0} is also provided, the number of samples tested is the maximum implied by the two options. If the option --{1} is provided, no remote files are verified.", "backup-test-samples", "no-backend-verification"); } } public static string BackendtestpercentageShort { get { return LC.L(@"The percentage of samples to test after a backup"); } } public static string FullremoteverificationLong(string optionname) { return LC.L(@"After a backup is completed, some (dblock, dindex, dlist) files from the remote backend are selected for verification. Use this option to turn on full verification, which will decrypt the files and examine the insides of each volume, instead of simply verifying the external hash. If the option --{0} is set, no remote files are verified. This option is automatically set when then verification is performed directly. ListAndIndexes is like True but only dlist and index volumes are handled.", optionname); } public static string FullremoteverificationShort { get { return LC.L(@"Activate in-depth verification of files"); } } @@ -258,6 +260,8 @@ namespace Duplicati.Library.Main.Strings public static string LogretentionShort { get { return LC.L(@"Clean up old log data"); } } public static string RepaironlypathsLong { get { return LC.L(@"Use this option to build a searchable local database which only contains path information. This option is usable for quickly building a database to locate certain content without needing to reconstruct all information. The resulting database can be searched, but cannot be used to restore data with."); } } public static string RepaironlypathsShort { get { return LC.L(@"Repair database with paths"); } } + public static string RepairignoreoutdateddatabaseLong { get { return LC.L(@"Use this option to ignore that the remote destination contains newer contents than the database. This should only be used when the database is known to be correct, but the remote destination is not, as data can be lost otherwise."); } } + public static string RepairignoreoutdateddatabaseShort { get { return LC.L(@"Ignore outdated database files"); } } public static string ForcelocaleLong { get { return LC.L(@"By default, your system locale and culture settings will be used. In some cases you may prefer to run with another locale, for example to get messages in another language. Use this option to set the locale. Supply a blank string to choose the ""Invariant Culture""."); } } public static string ForcelocaleShort { get { return LC.L(@"Force the locale setting"); } } public static string ForceActualDateLong { get { return LC.L(@"By default, dates are displayed in the calendar format, meaning ""Today"" or ""Last Thursday"". By setting this option, only the actual dates are displayed, ""Nov 12, 2018, 8:01 AM"" for example."); } } @@ -345,6 +349,8 @@ namespace Duplicati.Library.Main.Strings public static string RestoreVolumeDecryptorsLong { get { return LC.L("Use this option to set the number of concurrent FileDecryptor processes used during restore. A FileDecryptor processes one volume at a time, and increasing the number of FileDecryptors may improve restore performance if the bottleneck is decryption."); } } public static string RestoreVolumeDownloadersShort { get { return LC.L("Number of concurrent FileDownloader processes used during restore"); } } public static string RestoreVolumeDownloadersLong { get { return LC.L("Use this option to set the number of concurrent FileDownloader processes used during restore. A FileDownloader processes one volume at a time, and increasing the number of FileDownloaders may improve restore performance if the bottleneck is downloading."); } } + public static string RestoreChannelBufferSizeShort { get { return LC.L("Size of buffers of the channels used during restore"); } } + public static string RestoreChannelBufferSizeLong { get { return LC.L("Use this option to set the size of the buffers of the channels used during restore. The buffers are used to allow for better asynchronous communication between the processes in the restore flow. Increasing the buffer size may improve restore performance."); } } public static string InternalProfilingShort { get { return LC.L("Enable internal profiling"); } } public static string InternalProfilingLong { get { return LC.L("Use this option to enable internal profiling. Profiling is used to measure the performance of the internal code. The profiling data is written to the log file and can be used to identify performance bottlenecks."); } } } diff --git a/Duplicati/Library/Main/Utility.cs b/Duplicati/Library/Main/Utility.cs index ae80bce81..844d28b56 100644 --- a/Duplicati/Library/Main/Utility.cs +++ b/Duplicati/Library/Main/Utility.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -167,7 +167,6 @@ namespace Duplicati.Library.Main newDict.Add("passphrase", Library.Utility.Utility.ByteArrayAsHexString(Library.Utility.Utility.RepeatedHashWithSalt(options.Passphrase, salt, 1200))); } - var needsUpdate = false; foreach (var k in newDict) if (!opts.ContainsKey(k.Key)) diff --git a/Duplicati/Library/Main/Volumes/FilesetVolumeWriter.cs b/Duplicati/Library/Main/Volumes/FilesetVolumeWriter.cs index a4845b47b..4bf79d88e 100644 --- a/Duplicati/Library/Main/Volumes/FilesetVolumeWriter.cs +++ b/Duplicati/Library/Main/Volumes/FilesetVolumeWriter.cs @@ -1,28 +1,32 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; using System.IO; using Newtonsoft.Json; using Duplicati.Library.Interface; +using Duplicati.Library.Main.Database; +using Duplicati.Library.Main.Operation.Common; +using System.Threading.Tasks; +using Duplicati.Library.Utility; namespace Duplicati.Library.Main.Volumes { @@ -146,7 +150,7 @@ namespace Duplicati.Library.Main.Volumes m_writer.WriteValue(name); if (metahash != null) WriteMetaProperties(metahash, metasize, metablockhash, metablocklisthashes); - + m_writer.WriteEndObject(); } @@ -222,5 +226,59 @@ namespace Duplicati.Library.Main.Volumes sr.Write(FilesetData.GetFilesetInstance(isFullBackup)); } } + + /// + /// Probes for an unused filename, using the current time as a starting point + /// + /// The database to check for clashes + /// The options to use for the filename + /// The starting time to probe from + /// The time to increment by each probe + /// The maximum number of tries to probe + /// The first unused filename + internal static Task ProbeUnusedFilenameName(DatabaseCommon database, Options options, DateTime start, TimeSpan increment = default, int maxTries = 60) + => ProbeUnusedFilenameName(name => database.GetRemoteVolumeIDAsync(name), options, start, increment, maxTries); + + /// + /// Probes for an unused filename, using the current time as a starting point + /// + /// The database to check for clashes + /// The options to use for the filename + /// The starting time to probe from + /// The time to increment by each probe + /// The maximum number of tries to probe + /// The first unused filename + internal static DateTime ProbeUnusedFilenameName(LocalDatabase database, Options options, DateTime start, TimeSpan increment = default, int maxTries = 60) + => ProbeUnusedFilenameName((name) => Task.FromResult(database.GetRemoteVolumeID(name)), options, start, increment, maxTries).Await(); + + /// + /// Probes for an unused filename, using the current time as a starting point + /// + /// The function to use to probe for the ID + /// The options to use for the filename + /// The starting time to probe from + /// The time to increment by each probe + /// The maximum number of tries to probe + /// The first unused filename + private static async Task ProbeUnusedFilenameName(Func> ProbeForId, Options options, DateTime start, TimeSpan increment = default, int maxTries = 60) + { + if (increment == default) + increment = TimeSpan.FromSeconds(1); + + var s = 1; + var time = start; + + while (s < maxTries) + { + var id = await ProbeForId(GenerateFilename(RemoteVolumeType.Files, options, null, time)).ConfigureAwait(false); + if (id < 0) + return time; + + time += increment; + } + + throw new UserInformationException($"Failed to find an unused filename, tried {increment} * {maxTries} from {start}", "FailedToFindUnusedFilename"); + + } } } diff --git a/Duplicati/Library/Main/Volumes/VolumeReaderBase.cs b/Duplicati/Library/Main/Volumes/VolumeReaderBase.cs index fa2b8dffd..fffab0150 100644 --- a/Duplicati/Library/Main/Volumes/VolumeReaderBase.cs +++ b/Duplicati/Library/Main/Volumes/VolumeReaderBase.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Interface; using Duplicati.Library.Utility; @@ -116,7 +116,12 @@ namespace Duplicati.Library.Main.Volumes { using (var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read)) using (var c = LoadCompressor(compressor, stream, options)) - using (var s = c.OpenRead(FILESET_FILENAME)) + return GetFilesetData(c, options); + } + + public static FilesetData GetFilesetData(ICompression compressor, Options options) + { + using (var s = compressor.OpenRead(FILESET_FILENAME)) { if (s == null) { @@ -140,7 +145,19 @@ namespace Duplicati.Library.Main.Volumes { using (var stream = new System.IO.FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read)) using (var c = LoadCompressor(compressor, stream, options)) - using (var s = c.OpenRead(MANIFEST_FILENAME)) + UpdateOptionsFromManifest(c, options); + + } + + /// + /// Updates the options with data from the manifest file, but does not overwrite existing values + /// + /// The compressor to use + /// The file to read the manifest from + /// The options to update + public static void UpdateOptionsFromManifest(ICompression compressor, Options options) + { + using (var s = compressor.OpenRead(MANIFEST_FILENAME)) using (var fs = new StreamReader(s, ENCODING)) { var d = JsonConvert.DeserializeObject(fs.ReadToEnd()); diff --git a/Duplicati/Library/Modules/Builtin/Duplicati.Library.Modules.Builtin.csproj b/Duplicati/Library/Modules/Builtin/Duplicati.Library.Modules.Builtin.csproj index 3d2055c85..e3406f9fa 100644 --- a/Duplicati/Library/Modules/Builtin/Duplicati.Library.Modules.Builtin.csproj +++ b/Duplicati/Library/Modules/Builtin/Duplicati.Library.Modules.Builtin.csproj @@ -23,11 +23,17 @@ - - - - PreserveNewest - + + + + PreserveNewest + + + + + + PreserveNewest + diff --git a/Duplicati/Library/Modules/Builtin/HyperVOptions.cs b/Duplicati/Library/Modules/Builtin/HyperVOptions.cs index 2df703c2e..67bda3d9e 100644 --- a/Duplicati/Library/Modules/Builtin/HyperVOptions.cs +++ b/Duplicati/Library/Modules/Builtin/HyperVOptions.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -24,8 +24,8 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.Versioning; using System.Text.RegularExpressions; -using Duplicati.Library.Common; using Duplicati.Library.Snapshots; +using Duplicati.Library.Snapshots.Windows; namespace Duplicati.Library.Modules.Builtin { @@ -149,7 +149,8 @@ namespace Duplicati.Library.Modules.Builtin Logging.Log.WriteWarningMessage(LOGTAG, "HyperVOnServerOnly", null, "This is client version of Windows. Hyper-V VSS writer is present only on Server version. Backup will continue, but will be crash consistent only in opposite to application consistent in Server version"); Logging.Log.WriteInformationMessage(LOGTAG, "StartingHyperVQuery", "Starting to gather Hyper-V information"); - hypervUtility.QueryHyperVGuestsInfo(true); + var provider = Utility.Utility.ParseEnumOption(changedOptions.AsReadOnly(), "snapshot-provider", SnapshotProvider.AlphaVSS); + hypervUtility.QueryHyperVGuestsInfo(provider, true); Logging.Log.WriteInformationMessage(LOGTAG, "HyperVMachineCount", "Found {0} virtual machines on Hyper-V", hypervUtility.Guests.Count); foreach (var guest in hypervUtility.Guests) diff --git a/Duplicati/Library/Modules/Builtin/MSSQLOptions.cs b/Duplicati/Library/Modules/Builtin/MSSQLOptions.cs index d41f84009..058b119c5 100644 --- a/Duplicati/Library/Modules/Builtin/MSSQLOptions.cs +++ b/Duplicati/Library/Modules/Builtin/MSSQLOptions.cs @@ -1,31 +1,31 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; using System.Linq; using System.Runtime.Versioning; using System.Text.RegularExpressions; -using Duplicati.Library.Common; using Duplicati.Library.Snapshots; +using Duplicati.Library.Snapshots.Windows; namespace Duplicati.Library.Modules.Builtin { @@ -144,7 +144,8 @@ namespace Duplicati.Library.Modules.Builtin } Logging.Log.WriteInformationMessage(LOGTAG, "StartingMsSqlQuery", "Starting to gather Microsoft SQL Server information", Logging.LogMessageType.Information); - mssqlUtility.QueryDBsInfo(); + var provider = Utility.Utility.ParseEnumOption(changedOptions.AsReadOnly(), "snapshot-provider", SnapshotProvider.AlphaVSS); + mssqlUtility.QueryDBsInfo(provider); Logging.Log.WriteInformationMessage(LOGTAG, "MsSqlDatabaseCount", "Found {0} databases on Microsoft SQL Server", mssqlUtility.DBs.Count); foreach(var db in mssqlUtility.DBs) diff --git a/Duplicati/Library/Modules/Builtin/ReportHelper.cs b/Duplicati/Library/Modules/Builtin/ReportHelper.cs index 5e9db9a23..e37ee17ac 100644 --- a/Duplicati/Library/Modules/Builtin/ReportHelper.cs +++ b/Duplicati/Library/Modules/Builtin/ReportHelper.cs @@ -397,21 +397,21 @@ namespace Duplicati.Library.Modules.Builtin case PARSEDRESULT: return m_parsedresultlevel; case MACHINE_ID: - return AutoUpdater.UpdaterManager.MachineID; + return AutoUpdater.DataFolderManager.MachineID; case BACKUP_ID: return Utility.Utility.ByteArrayAsHexString(Utility.Utility.RepeatedHashWithSalt(m_remoteurl, SALT)); case BACKUP_NAME: return System.IO.Path.GetFileNameWithoutExtension(Utility.Utility.getEntryAssembly().Location); case MACHINE_NAME: - return AutoUpdater.UpdaterManager.MachineName; + return AutoUpdater.DataFolderManager.MachineName; case OPERATING_SYSTEM: - return OperatingSystem.IsWindows() ? "Windows" : OperatingSystem.IsLinux() ? "Linux" : OperatingSystem.IsMacOS() ? "MacOS" : "Unknown"; + return AutoUpdater.UpdaterManager.OperatingSystemName; case INSTALLATION_TYPE: return AutoUpdater.UpdaterManager.PackageTypeId; case DESTINATION_TYPE: // Only return the url scheme, as the rest could contain sensitive information var ix = m_remoteurl?.IndexOf("://", StringComparison.OrdinalIgnoreCase) ?? -1; - return ix >= 0 && ix < 15 ? m_remoteurl.Substring(0, ix) : "file"; + return Utility.Utility.GuessScheme(m_remoteurl) ?? "file"; default: return null; } diff --git a/Duplicati/Library/Modules/Builtin/SendHttpMessage.cs b/Duplicati/Library/Modules/Builtin/SendHttpMessage.cs index 952dea7fc..6bd57fdec 100644 --- a/Duplicati/Library/Modules/Builtin/SendHttpMessage.cs +++ b/Duplicati/Library/Modules/Builtin/SendHttpMessage.cs @@ -28,6 +28,7 @@ using System.Text; using System.Threading.Tasks; using Duplicati.Library.Utility; using Uri = System.Uri; +using System.Threading; namespace Duplicati.Library.Modules.Builtin { @@ -113,6 +114,15 @@ namespace Duplicati.Library.Modules.Builtin /// private const string OPTION_ACCEPT_ANY_CERTIFICATE = "send-http-accept-any-ssl-certificate"; + /// + /// The option used to specify the number of retries for sending the HTTP request + /// + private const string OPTION_SEND_HTTP_RETRIES = "send-http-retries"; + /// + /// The option used to specify the delay between retries for sending the HTTP request + /// + private const string OPTION_SEND_HTTP_RETRY_DELAY = "send-http-retry-delay"; + #endregion #region Option defaults @@ -128,6 +138,14 @@ namespace Duplicati.Library.Modules.Builtin /// Don't use the subject for HTTP /// protected override string DEFAULT_SUBJECT => string.Empty; + /// + /// The default number of retries + /// + private const int DEFAULT_RETRIES = 3; + /// + /// The default delay between retries + /// + private const string DEFAULT_RETRY_DELAY = "1s"; #endregion #region Private variables @@ -154,6 +172,15 @@ namespace Duplicati.Library.Modules.Builtin /// private string[] m_acceptSpecificCertificates; + /// + /// The number of retries to attempt + /// + private int m_retries; + /// + /// The delay between retries + /// + private TimeSpan m_retryDelay; + #endregion @@ -208,6 +235,9 @@ namespace Duplicati.Library.Modules.Builtin new CommandLineArgument(OPTION_ACCEPT_ANY_CERTIFICATE, CommandLineArgument.ArgumentType.Boolean, Strings.SendHttpMessage.AcceptAnyCertificateShort, Strings.SendHttpMessage.AcceptAnyCertificateLong), new CommandLineArgument(OPTION_ACCEPT_SPECIFIED_CERTIFICATE, CommandLineArgument.ArgumentType.String, Strings.SendHttpMessage.AcceptSpecifiedCertificateShort, Strings.SendHttpMessage.AcceptSpecifiedCertificateLong), + + new CommandLineArgument(OPTION_SEND_HTTP_RETRIES, CommandLineArgument.ArgumentType.Integer, Strings.SendHttpMessage.SendHttpRetriesShort, Strings.SendHttpMessage.SendHttpRetriesLong, DEFAULT_RETRIES.ToString()), + new CommandLineArgument(OPTION_SEND_HTTP_RETRY_DELAY, CommandLineArgument.ArgumentType.Integer, Strings.SendHttpMessage.SendHttpRetryDelayShort, Strings.SendHttpMessage.SendHttpRetryDelayLong, DEFAULT_RETRY_DELAY), }); } } @@ -266,9 +296,12 @@ namespace Duplicati.Library.Modules.Builtin m_messageParameterName = DEFAULT_MESSAGE_PARAMETER_NAME; commandlineOptions.TryGetValue(OPTION_EXTRA_PARAMETERS, out m_extraParameters); - m_acceptAnyCertificate = commandlineOptions.ContainsKey(OPTION_ACCEPT_ANY_CERTIFICATE) && Utility.Utility.ParseBoolOption(commandlineOptions.AsReadOnly(), OPTION_ACCEPT_ANY_CERTIFICATE); + m_acceptAnyCertificate = Utility.Utility.ParseBoolOption(commandlineOptions.AsReadOnly(), OPTION_ACCEPT_ANY_CERTIFICATE); m_acceptSpecificCertificates = commandlineOptions.ContainsKey(OPTION_ACCEPT_SPECIFIED_CERTIFICATE) ? commandlineOptions[OPTION_ACCEPT_SPECIFIED_CERTIFICATE].Split([",", ";"], StringSplitOptions.RemoveEmptyEntries) : null; + m_retries = Utility.Utility.ParseIntOption(commandlineOptions.AsReadOnly(), OPTION_SEND_HTTP_RETRIES, DEFAULT_RETRIES); + m_retryDelay = Utility.Utility.ParseTimespanOption(commandlineOptions.AsReadOnly(), OPTION_SEND_HTTP_RETRY_DELAY, DEFAULT_RETRY_DELAY); + return true; } @@ -289,7 +322,7 @@ namespace Duplicati.Library.Modules.Builtin contenttype = new MediaTypeHeaderValue("application/x-www-form-urlencoded"); var postData = $"{m_messageParameterName}={System.Uri.EscapeDataString(body)}"; if (!string.IsNullOrEmpty(m_extraParameters)) - postData += $"&{System.Uri.EscapeDataString(m_extraParameters)}"; + postData += $"&{m_extraParameters}"; data = Encoding.UTF8.GetBytes(postData); } @@ -301,7 +334,9 @@ namespace Duplicati.Library.Modules.Builtin }; request.Content.Headers.ContentType = contenttype; - try + Exception lastEx = null; + + await RetryHelper.Retry(async () => { var response = await client.SendAsync(request); var responseContent = await response.Content.ReadAsStringAsync(); @@ -315,18 +350,17 @@ namespace Duplicati.Library.Modules.Builtin ); response.EnsureSuccessStatusCode(); - } - catch (Exception ex) + lastEx = null; + }, (ex, retry) => { - Logging.Log.WriteWarningMessage(LOGTAG, "HttpResponseError", ex, "HTTP Response request failed for: {0}", target.Url); - return ex; - } + Logging.Log.WriteWarningMessage(LOGTAG, "HttpResponseError", ex, "HTTP Response request attempt {0} of {1} failed for: {2}", retry, m_retries, target.Url); + lastEx = ex; + }, m_retries, m_retryDelay, CancellationToken.None); - return null; + return lastEx; } private Dictionary m_cachedBodyResults; - private string m_form_body = string.Empty; protected override string ReplaceTemplate(string input, object result, Exception exception, bool subjectline) { @@ -364,7 +398,7 @@ namespace Duplicati.Library.Modules.Builtin foreach (var target in m_report_targets) { if (m_cachedBodyResults.TryGetValue(target.Format, out var result)) - ex ??= SendMessage(client, target, subject, result).ConfigureAwait(false).GetAwaiter().GetResult(); + ex ??= SendMessage(client, target, subject, result).Await(); } if (ex != null) diff --git a/Duplicati/Library/Modules/Builtin/SendJabberMessage.cs b/Duplicati/Library/Modules/Builtin/SendJabberMessage.cs index 2f8dac808..81c54e07b 100644 --- a/Duplicati/Library/Modules/Builtin/SendJabberMessage.cs +++ b/Duplicati/Library/Modules/Builtin/SendJabberMessage.cs @@ -22,9 +22,6 @@ using System; using System.Collections.Generic; using System.Linq; using Duplicati.Library.Interface; -using Duplicati.Library.Logging; -using System.Net.NetworkInformation; -using Duplicati.Library.Modules.Builtin.ResultSerialization; using Artalk.Xmpp.Client; namespace Duplicati.Library.Modules.Builtin @@ -195,8 +192,7 @@ namespace Duplicati.Library.Modules.Builtin protected override void SendMessage(string subject, string body) { - - var uri = new Library.Utility.Uri(m_username.Contains("://") ? m_username : "http://" + m_username); + var uri = new Utility.Uri(m_username.Contains("://") ? m_username : "http://" + m_username); var resource = uri.Path ?? ""; if (resource.StartsWith("/", StringComparison.Ordinal)) resource = resource.Substring(1); diff --git a/Duplicati/Library/Modules/Builtin/SendMail.cs b/Duplicati/Library/Modules/Builtin/SendMail.cs index beac39fee..9dc2b9e03 100644 --- a/Duplicati/Library/Modules/Builtin/SendMail.cs +++ b/Duplicati/Library/Modules/Builtin/SendMail.cs @@ -1,39 +1,31 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; -using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Net; using System.Text; -using System.Text.RegularExpressions; using Duplicati.Library.Interface; -using Duplicati.Library.Logging; -using Duplicati.Library.Utility; -using System.Net.NetworkInformation; -using System.Net.Sockets; using MailKit.Net.Smtp; using MimeKit; -using Duplicati.Library.Modules.Builtin.ResultSerialization; using DnsClient; namespace Duplicati.Library.Modules.Builtin @@ -277,7 +269,7 @@ namespace Duplicati.Library.Modules.Builtin lastServer = server; try { - using (MemoryStream ms = new MemoryStream()) + using (var ms = new MemoryStream()) { try { diff --git a/Duplicati/Library/Modules/Builtin/SendTelegramMessage.cs b/Duplicati/Library/Modules/Builtin/SendTelegramMessage.cs index 394d41c31..b4a73948a 100644 --- a/Duplicati/Library/Modules/Builtin/SendTelegramMessage.cs +++ b/Duplicati/Library/Modules/Builtin/SendTelegramMessage.cs @@ -56,6 +56,10 @@ public class SendTelegramMessage : ReportHelper /// private const string OPTION_CHANNEL = "send-telegram-channel-id"; /// + /// Option used to specify the topic ID to be used in telegram groups. + /// + private const string OPTION_TOPICID = "send-telegram-topid-id"; + /// /// Option used to specify report body /// private const string OPTION_MESSAGE = "send-telegram-message"; @@ -133,20 +137,19 @@ public class SendTelegramMessage : ReportHelper /// public override IList SupportedCommands => [ - new CommandLineArgument(OPTION_CHANNEL, CommandLineArgument.ArgumentType.String, Strings.SendTelegramMessage.SendtelegramchannelShort, Strings.SendTelegramMessage.SendtelegramchannelLong), - new CommandLineArgument(OPTION_MESSAGE, CommandLineArgument.ArgumentType.String, Strings.SendTelegramMessage.SendtelegrammessageShort, Strings.SendTelegramMessage.SendtelegrammessageLong, DEFAULT_BODY), - new CommandLineArgument(OPTION_BOTID, CommandLineArgument.ArgumentType.String, Strings.SendTelegramMessage.SendtelegrambotidShort, Strings.SendTelegramMessage.SendtelegrambotidLong), - new CommandLineArgument(OPTION_APIKEY, CommandLineArgument.ArgumentType.String, Strings.SendTelegramMessage.SendtelegramapikeyShort, Strings.SendTelegramMessage.SendtelegramapikeyLong), - new CommandLineArgument(OPTION_SENDLEVEL, CommandLineArgument.ArgumentType.String, Strings.SendTelegramMessage.SendtelegramlevelShort, Strings.SendTelegramMessage.SendtelegramlevelLong(ParsedResultType.Success.ToString(), ParsedResultType.Warning.ToString(), ParsedResultType.Error.ToString(), ParsedResultType.Fatal.ToString(), "All"), DEFAULT_LEVEL, null, Enum.GetNames(typeof(ParsedResultType)).Union(new string[] { "All" } ).ToArray()), - new CommandLineArgument(OPTION_SENDALL, CommandLineArgument.ArgumentType.Boolean, Strings.SendTelegramMessage.SendtelegramanyoperationShort, Strings.SendTelegramMessage.SendtelegramanyoperationLong), - + new CommandLineArgument(OPTION_CHANNEL, CommandLineArgument.ArgumentType.String, Strings.SendTelegramMessage.SendTelegramChannelShort, Strings.SendTelegramMessage.SendTelegramChannelLong), + new CommandLineArgument(OPTION_MESSAGE, CommandLineArgument.ArgumentType.String, Strings.SendTelegramMessage.SendTelegramMessageShort, Strings.SendTelegramMessage.SendTelegramMessageLong, DEFAULT_BODY), + new CommandLineArgument(OPTION_BOTID, CommandLineArgument.ArgumentType.String, Strings.SendTelegramMessage.SendTelegramBotIdShort, Strings.SendTelegramMessage.SendTelegramBotIdLong), + new CommandLineArgument(OPTION_APIKEY, CommandLineArgument.ArgumentType.String, Strings.SendTelegramMessage.SendTelegramApiKeyShort, Strings.SendTelegramMessage.SendTelegramApiKeyLong), + new CommandLineArgument(OPTION_SENDLEVEL, CommandLineArgument.ArgumentType.String, Strings.SendTelegramMessage.SendTelegramLevelShort, Strings.SendTelegramMessage.SendTelegramlevelLong(ParsedResultType.Success.ToString(), ParsedResultType.Warning.ToString(), ParsedResultType.Error.ToString(), ParsedResultType.Fatal.ToString(), "All"), DEFAULT_LEVEL, null, Enum.GetNames(typeof(ParsedResultType)).Union(new string[] { "All" } ).ToArray()), + new CommandLineArgument(OPTION_SENDALL, CommandLineArgument.ArgumentType.Boolean, Strings.SendTelegramMessage.SendTelegramManyOperationShort, Strings.SendTelegramMessage.SendTelegramManyOperationLong), new CommandLineArgument(OPTION_LOG_LEVEL, CommandLineArgument.ArgumentType.Enumeration, Strings.ReportHelper.OptionLoglevelShort, Strings.ReportHelper.OptionLoglevelLong, DEFAULT_LOG_LEVEL.ToString(), null, Enum.GetNames(typeof(Logging.LogMessageType))), new CommandLineArgument(OPTION_LOG_FILTER, CommandLineArgument.ArgumentType.String, Strings.ReportHelper.OptionLogfilterShort, Strings.ReportHelper.OptionLogfilterLong), new CommandLineArgument(OPTION_MAX_LOG_LINES, CommandLineArgument.ArgumentType.Integer, Strings.ReportHelper.OptionmaxloglinesShort, Strings.ReportHelper.OptionmaxloglinesLong, DEFAULT_LOGLINES.ToString()), - new CommandLineArgument(OPTION_RESULT_FORMAT, CommandLineArgument.ArgumentType.Enumeration, Strings.ReportHelper.ResultFormatShort, Strings.ReportHelper.ResultFormatLong(Enum.GetNames(typeof(ResultExportFormat))), DEFAULT_EXPORT_FORMAT.ToString(), null, Enum.GetNames(typeof(ResultExportFormat))), + new CommandLineArgument(OPTION_TOPICID, CommandLineArgument.ArgumentType.String, Strings.SendTelegramMessage.SendTelegramTopicShort, Strings.SendTelegramMessage.SendTelegramTopicLong), ]; - + protected override string SubjectOptionName => OPTION_MESSAGE; protected override string BodyOptionName => OPTION_MESSAGE; protected override string ActionLevelOptionName => OPTION_SENDLEVEL; @@ -168,6 +171,10 @@ public class SendTelegramMessage : ReportHelper /// The Telegram ChannelID /// private string m_channelId; + /// + /// The telegram TopicID + /// + private string m_topicId; /// /// This method is the interception where the module can interact with the execution environment and modify the settings. @@ -182,12 +189,14 @@ public class SendTelegramMessage : ReportHelper commandlineOptions.TryGetValue(OPTION_BOTID, out m_botid); commandlineOptions.TryGetValue(OPTION_APIKEY, out m_apikey); + commandlineOptions.TryGetValue(OPTION_TOPICID, out m_topicId); return true; } #endregion - + + /// protected override string ReplaceTemplate(string input, object result, Exception exception, bool subjectline) { // No need to do the expansion as we throw away the result @@ -220,7 +229,7 @@ public class SendTelegramMessage : ReportHelper // Send all chunks sequentially for (var i = 0; i < messages.Length; i++) - await SendMessageChunk(messages[i], i + 1, messages.Length); + await SendMessageChunk(messages[i], i + 1, messages.Length).ConfigureAwait(false); } catch (Exception e) { @@ -239,9 +248,15 @@ public class SendTelegramMessage : ReportHelper { try { + if (string.IsNullOrWhiteSpace(m_botid)) + throw new Exception("Telegram Bot ID is required and not set"); + if (string.IsNullOrWhiteSpace(m_apikey)) + throw new Exception("Telegram API Key is required and not set"); + var p = new { chat_id = Uri.EscapeDataString(m_channelId), + message_thread_id = string.IsNullOrWhiteSpace(m_topicId) ? null : Uri.EscapeDataString(m_topicId), parse_mode = "Markdown", text = Uri.EscapeDataString(totalParts > 1 ? $"Part {partNumber}/{totalParts}:\n{message}" @@ -250,13 +265,16 @@ public class SendTelegramMessage : ReportHelper apiKey = Uri.EscapeDataString(m_apikey) }; - var url = $"https://api.telegram.org/bot{p.botId}:{p.apiKey}/sendMessage?chat_id={p.chat_id}&parse_mode={p.parse_mode}&text={p.text}"; + var baseUrl = $"https://api.telegram.org/bot{p.botId}:{p.apiKey}/sendMessage"; + var url = $"{baseUrl}?chat_id={p.chat_id}&parse_mode={p.parse_mode}&text={p.text}" + + $"{(!string.IsNullOrWhiteSpace(m_topicId) ? $"&message_thread_id={m_topicId}" : string.Empty)}"; + using var timeoutToken = new CancellationTokenSource(); timeoutToken.CancelAfter(REQUEST_TIMEOUT); using var client = HttpClientHelper.CreateClient(); - var response = await client.GetAsync(url, timeoutToken.Token); - var responseContent = await response.Content.ReadAsStringAsync(timeoutToken.Token); + var response = await client.GetAsync(url, timeoutToken.Token).ConfigureAwait(false); + var responseContent = await response.Content.ReadAsStringAsync(timeoutToken.Token).ConfigureAwait(false); if (!responseContent.Contains("\"ok\":true")) Logging.Log.WriteWarningMessage(LOGTAG, "telegramSendError", null, diff --git a/Duplicati/Library/Modules/Builtin/Strings.cs b/Duplicati/Library/Modules/Builtin/Strings.cs index e4d9cc974..bcd66b666 100644 --- a/Duplicati/Library/Modules/Builtin/Strings.cs +++ b/Duplicati/Library/Modules/Builtin/Strings.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Localization.Short; using System; @@ -167,11 +167,12 @@ You can supply multiple options with a comma separator, e.g. ""{0},{1}"". The sp internal static class SendTelegramMessage { - public static string Description { get { return LC.L(@"This module provides support for sending status reports via Telegram messages"); } } - public static string DisplayName { get { return LC.L(@"Telegram report module"); } } - public static string SendtelegramchannelLong { get { return LC.L(@"Use this option to set the channel ID."); } } - public static string SendtelegramchannelShort { get { return LC.L(@"Telegram channel ID"); } } - public static string SendtelegrammessageLong { get { return LC.L(@"This value can be a filename. If the file exists, the file contents will be used as the message. + public static string Description => LC.L(@"This module provides support for sending status reports via Telegram messages"); + public static string DisplayName => LC.L(@"Telegram report module"); + public static string SendTelegramChannelLong => LC.L(@"Use this option to set the channel ID."); + public static string SendTelegramChannelShort => LC.L(@"Telegram channel ID"); + public static string SendTelegramMessageLong => + LC.L(@"This value can be a filename. If the file exists, the file contents will be used as the message. In the message, certain tokens are replaced: %OPERATIONNAME% - The name of the operation, normally ""Backup"" @@ -179,19 +180,20 @@ In the message, certain tokens are replaced: %LOCALPATH% - The path to the local files or folders involved in the operation (if any) %PARSEDRESULT% - The parsed result, if the operation is a backup. Possible values are: Error, Warning, Success -All command line options are also reported within %value%, e.g. %volsize%. Any unknown/unset value is removed."); } } - public static string SendtelegrammessageShort { get { return LC.L(@"The message template"); } } - public static string SendtelegrambotidLong { get { return LC.L(@"Use this option to set a bot ID for the bot that will send the message."); } } +All command line options are also reported within %value%, e.g. %volsize%. Any unknown/unset value is removed."); - public static string SendtelegrambotidShort { get { return LC.L(@"The Telegram bot ID"); } } - public static string SendtelegramapikeyLong { get { return LC.L(@"Use this option to set a API key for the bot that will send the message."); } } - public static string SendtelegramapikeyShort { get { return LC.L(@"The Telegram API key"); } } - public static string SendtelegramlevelLong(string success, string warning, string error, string fatal, string all) { return LC.L(@"You can specify one of ""{0}"", ""{1}"", ""{2}"", ""{3}"". + public static string SendTelegramMessageShort => LC.L(@"The message template"); + public static string SendTelegramBotIdLong => LC.L(@"Use this option to set a bot ID for the bot that will send the message."); + public static string SendTelegramBotIdShort => LC.L(@"The Telegram bot ID"); + public static string SendTelegramApiKeyLong => LC.L(@"Use this option to set a API key for the bot that will send the message."); + public static string SendTelegramApiKeyShort => LC.L(@"The Telegram API key"); + public static string SendTelegramTopicShort => LC.L(@"The Telegram topic ID"); + public static string SendTelegramTopicLong => LC.L(@"Topic ID for the Topic in the telegram group. For more information on Telegram setup, refer to documentation."); + public static string SendTelegramlevelLong(string success, string warning, string error, string fatal, string all) { return LC.L(@"You can specify one of ""{0}"", ""{1}"", ""{2}"", ""{3}"". You can supply multiple options with a comma separator, e.g. ""{0},{1}"". The special value ""{4}"" is a shorthand for ""{0},{1},{2},{3}"" and will cause all backup operations to send a message.", success, warning, error, fatal, all); } - public static string SendtelegramlevelShort { get { return LC.L(@"The messages to send"); } } - public static string SendtelegramanyoperationLong { get { return LC.L(@"By default, messages will only be sent after a backup operation. Use this option to send messages for all operations."); } } - public static string SendtelegramanyoperationShort { get { return LC.L(@"Send messages for all operations"); } } - public static string LoginTimeoutError { get { return LC.L(@"Timeout occurred while sending to Telegram server"); } } + public static string SendTelegramLevelShort => LC.L(@"The messages to send"); + public static string SendTelegramManyOperationLong => LC.L(@"By default, messages will only be sent after a backup operation. Use this option to send messages for all operations."); + public static string SendTelegramManyOperationShort => LC.L(@"Send messages for all operations"); } internal static class SendHttpMessage @@ -229,6 +231,10 @@ You can supply multiple options with a comma separator, e.g. ""{0},{1}"". The sp public static string AcceptAnyCertificateShort { get { return LC.L(@"Accept any server certificate"); } } public static string AcceptSpecifiedCertificateLong { get { return LC.L(@"If your server certificate is reported as invalid (e.g. with self-signed certificates), you can supply the certificate hash (SHA1) to approve it anyway. The hash value must be entered in hex format without spaces or colons. You can enter multiple hashes separated by commas."); } } public static string AcceptSpecifiedCertificateShort { get { return LC.L(@"Optionally accept a known SSL certificate"); } } + public static string SendHttpRetriesLong { get { return LC.L(@"Use this option to set the number of retries to attempt if the HTTP request fails."); } } + public static string SendHttpRetriesShort { get { return LC.L(@"Set the number of retries"); } } + public static string SendHttpRetryDelayLong { get { return LC.L(@"Use this option to set the delay between retries."); } } + public static string SendHttpRetryDelayShort { get { return LC.L(@"Set the retry delay"); } } } internal static class ReportHelper diff --git a/Duplicati/Library/Modules/Builtin/run-script-example.bat b/Duplicati/Library/Modules/Builtin/run-script-example.bat index e858bbc2b..a1797c568 100644 --- a/Duplicati/Library/Modules/Builtin/run-script-example.bat +++ b/Duplicati/Library/Modules/Builtin/run-script-example.bat @@ -50,7 +50,12 @@ REM --run-script-with-arguments = REM If set to true, the script path will be parsed as a command line, and the REM arguments will be passed to the script. If set to false (default), REM the script path will used as a single path. -REM Use double- or single-quotes to pass arguments with spaces. +REM If you do not have spaces in your script path or arguments, simply enter +REM it as a string: +REM Example: --run-script-before="C:\path\to\script.bat arg1 arg2 --option1=a" +REM If you have spaces in the path or arguements, use double- or single-quotes +REM around the elements that have spaces, similar to how you would do +REM on the command line: REM Example: --run-script-before="\"C:\path to\script.bat\" \"arg1 \" arg2" diff --git a/Duplicati/Library/Modules/Builtin/run-script-example.sh b/Duplicati/Library/Modules/Builtin/run-script-example.sh index 8ce9ef965..4743a3a98 100755 --- a/Duplicati/Library/Modules/Builtin/run-script-example.sh +++ b/Duplicati/Library/Modules/Builtin/run-script-example.sh @@ -50,7 +50,11 @@ # If set to true, the script path will be parsed as a command line, and the # arguments will be passed to the script. If set to false (default), # the script path will used as a single path. -# Use double- or single-quotes to pass arguments with spaces. +# If you do not have spaces in your script path or arguments, simply enter +# it as a string: +# Example: --run-script-before="/path/to/script.sh arg1 arg2 --option=a" +# If you have spaces in the path or arguements, use double- or single-quotes +# around the elements that have spaces, similar to how you would do in a shell: # Example: --run-script-before="\"/path to/script.sh\" \"arg1 \" arg2" diff --git a/Duplicati/Library/RemoteControl/ExchangeDataTypes.cs b/Duplicati/Library/RemoteControl/ExchangeDataTypes.cs index bb4003848..6b6cf7fa4 100644 --- a/Duplicati/Library/RemoteControl/ExchangeDataTypes.cs +++ b/Duplicati/Library/RemoteControl/ExchangeDataTypes.cs @@ -45,6 +45,10 @@ internal enum MessageType /// Command, /// + /// A control message + /// + Control, + /// /// An unknown command /// Unknown @@ -56,7 +60,9 @@ internal enum MessageType /// The client token /// The client public key /// The version of the client -public record AuthMessage(string Token, string PublicKey, string ClientVersion, int ProtocolVersion); +/// The protocol version of the client +/// The optional metadata to send +public record AuthMessage(string Token, string PublicKey, string ClientVersion, int ProtocolVersion, Dictionary? Metadata); /// /// A message authentication response @@ -75,6 +81,35 @@ internal sealed record AuthResultMessage(bool? Accepted, bool? WillReplaceToken, /// The version of the server public sealed record WelcomeMessage(string PublicKeyHash, string MachineName, string ServerVersion, IEnumerable SupportedProtocolVersions); +/// +/// A message with a control request +/// +/// The control command being requested +/// The parameters for the command +public sealed record ControlRequestMessage(string Command, Dictionary Parameters) +{ + /// + /// The configure report url command + /// + public const string ConfigureReportUrlSet = "configure-report-url:set"; + /// + /// A control message to return if the report url is configured + /// + public const string ConfigureReportUrlGet = "configure-report-url:get"; + /// + /// The url parameter for the configure report url command + /// + public const string ConfigureReportUrlParameter = "url"; + +} +/// +/// A message with a control response +/// +/// Whether the command was successful +/// The output of the command +/// The error message if the command failed +public sealed record ControlResponseMessage(bool Success, Dictionary? Output, string? ErrorMessage); + /// /// A message to send a command /// @@ -180,6 +215,7 @@ internal sealed record EnvelopedMessage "auth" => MessageType.Auth, "pong" => MessageType.Pong, "command" => MessageType.Command, + "control" => MessageType.Control, _ => MessageType.Unknown }; } diff --git a/Duplicati/Library/RemoteControl/KeepRemoteConnection.cs b/Duplicati/Library/RemoteControl/KeepRemoteConnection.cs index c2ad64e63..a2d25790f 100644 --- a/Duplicati/Library/RemoteControl/KeepRemoteConnection.cs +++ b/Duplicati/Library/RemoteControl/KeepRemoteConnection.cs @@ -19,10 +19,12 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +using System.Diagnostics; using System.Net; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text.Json; +using Duplicati.Library.AutoUpdater; using Duplicati.Library.Logging; namespace Duplicati.Library.RemoteControl; @@ -133,12 +135,20 @@ public class KeepRemoteConnection : IDisposable /// private RSA? _serverPublicKey; + /// + /// The callback to call when connecting + /// + private readonly Func, Task>> _onConnect; /// /// The callback to call when rekeying /// private readonly Func _onReKey; /// - /// The callback to call when a message is received + /// The callback to call when a control message is received + /// + private readonly Func _onControl; + /// + /// The callback to call when a command message is received /// private readonly Func _onMessage; @@ -168,16 +178,32 @@ public class KeepRemoteConnection : IDisposable /// /// The url to use /// The JWT token to use + /// The certificate url to use /// The server keys to use /// The token to cancel the connection - private KeepRemoteConnection(string serverUrl, string JWT, string certificateUrl, IEnumerable serverKeys, CancellationToken cancellationToken, Func onReKey, Func onMessage) + /// The callback to call when connecting + /// The callback to call when rekeying + /// The callback to call when a control message is received + /// The callback to call when a command message is received + private KeepRemoteConnection( + string serverUrl, + string JWT, + string certificateUrl, + IEnumerable serverKeys, + CancellationToken cancellationToken, + Func, Task>> onConnect, + Func onReKey, + Func onControl, + Func onMessage) { _serverUrl = serverUrl; _certificateUrl = certificateUrl; _token = JWT; _serverKeys = serverKeys; _cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _onConnect = onConnect; _onReKey = onReKey; + _onControl = onControl; _onMessage = onMessage; _client = new Websocket.Client.WebsocketClient(new Uri(serverUrl)); @@ -300,13 +326,27 @@ public class KeepRemoteConnection : IDisposable } _state = ConnectionState.WelcomeReceived; + + // Prepare basic metadata and allow additional metadata to be added + var metadata = await _onConnect(new Dictionary() { + { "client-version", UpdaterManager.SelfVersion?.Version ?? "0.0.0.0" }, + { "client-id", ClientId }, + { "client-uptime", (DateTime.Now - Process.GetCurrentProcess().StartTime).ToString() }, + { "machine-name", DataFolderManager.MachineName }, + { "machine-id", DataFolderManager.MachineID }, + { "install-id", DataFolderManager.InstallID }, + { "machine-os", UpdaterManager.OperatingSystemName }, + { "package-id", UpdaterManager.PackageTypeId }, + }); + SendEnvelope( welcomeEnvelope.RespondWith( new AuthMessage( _token, ClientKey.ExportRSAPublicKeyPem(), - AutoUpdater.UpdaterManager.SelfVersion?.Version ?? "0.0.0", - PROTOCOL_VERSION + UpdaterManager.SelfVersion?.Version ?? "0.0.0.0", + PROTOCOL_VERSION, + metadata ), "auth" ), @@ -352,6 +392,13 @@ public class KeepRemoteConnection : IDisposable )); break; + case MessageType.Control: + await _onControl(new ControlMessage( + envelope.GetPayload(), + response => SendEnvelope(envelope.RespondWith(response)) + )); + break; + default: throw new ProtocolViolationException("Unexpected message"); } @@ -403,13 +450,24 @@ public class KeepRemoteConnection : IDisposable /// The certificate url to use /// The server keys to use /// The token to cancel the connection + /// The callback to call when connecting /// The callback to call when rekeying - /// The callback to call when a message is received + /// The callback to call when a control message is received + /// The callback to call when a command message is received /// - public static Task Start(string serverUrl, string JWT, string certificateUrl, IEnumerable serverKeys, CancellationToken cancellationToken, Func onReKey, Func onMessage) + public static Task Start( + string serverUrl, + string JWT, + string certificateUrl, + IEnumerable serverKeys, + CancellationToken cancellationToken, + Func, Task>> onConnect, + Func onReKey, + Func onControl, + Func onMessage) => Task.Run(async () => { - using var connection = new KeepRemoteConnection(serverUrl, JWT, certificateUrl, serverKeys, cancellationToken, onReKey, onMessage); + using var connection = new KeepRemoteConnection(serverUrl, JWT, certificateUrl, serverKeys, cancellationToken, onConnect, onReKey, onControl, onMessage); await connection._runnerTask; }); @@ -477,14 +535,24 @@ public class KeepRemoteConnection : IDisposable /// /// The url to use /// The JWT token to use - /// + /// The certificate url to use /// The server keys to use + /// The callback to call when connecting /// The callback to call when rekeying + /// The callback to call when a control message is received /// The callback to call when a message is received - /// The token to cancel the connection /// The connection object - public static KeepRemoteConnection CreateRemoteListener(string serverUrl, string JWT, string certificateUrl, IEnumerable serverKeys, CancellationToken cancellationToken, Func onReKey, Func onMessage) - => new KeepRemoteConnection(serverUrl, JWT, certificateUrl, serverKeys, cancellationToken, onReKey, onMessage); + public static KeepRemoteConnection CreateRemoteListener( + string serverUrl, + string JWT, + string certificateUrl, + IEnumerable serverKeys, + CancellationToken cancellationToken, + Func, Task>> onConnect, + Func onReKey, + Func onControl, + Func onMessage) + => new KeepRemoteConnection(serverUrl, JWT, certificateUrl, serverKeys, cancellationToken, onConnect, onReKey, onControl, onMessage); /// /// Requests a certificate refresh @@ -587,4 +655,38 @@ public class KeepRemoteConnection : IDisposable } } } + + /// + /// A wrapper for allowing external code to handle a control message + /// + public sealed class ControlMessage + { + /// + /// The callback method that will receive the response + /// + private readonly Func _respondCommand; + /// + /// The command request message + /// + public ControlRequestMessage ControlRequestMessage { get; } + + /// + /// Creates a new command message + /// + /// The command request message + /// The callback method that will receive the response + public ControlMessage(ControlRequestMessage controlRequestMessage, Func respondCommand) + { + ControlRequestMessage = controlRequestMessage; + _respondCommand = respondCommand; + } + + /// + /// Responds to the command message + /// + /// The response to send + /// True if the response was sent + public bool Respond(ControlResponseMessage response) + => _respondCommand(response); + } } diff --git a/Duplicati/Library/RemoteControl/RegisterForRemote.cs b/Duplicati/Library/RemoteControl/RegisterForRemote.cs index 85ea09776..6de30c610 100644 --- a/Duplicati/Library/RemoteControl/RegisterForRemote.cs +++ b/Duplicati/Library/RemoteControl/RegisterForRemote.cs @@ -219,12 +219,13 @@ public class RegisterForRemote : IDisposable var basics = new Dictionary { { "instanceId", ClientInstanceId }, - { "machineId", AutoUpdater.UpdaterManager.MachineID }, - { "machineName", AutoUpdater.UpdaterManager.MachineName }, - { "installId", AutoUpdater.UpdaterManager.InstallID }, + { "machineId", AutoUpdater.DataFolderManager.MachineID }, + { "machineName", AutoUpdater.DataFolderManager.MachineName }, + { "installId", AutoUpdater.DataFolderManager.InstallID }, { "localTime", DateTimeOffset.Now.ToString("o") }, { "version", AutoUpdater.UpdaterManager.SelfVersion?.Version }, - { "packageTypeId", AutoUpdater.UpdaterManager.PackageTypeId } + { "packageTypeId", AutoUpdater.UpdaterManager.PackageTypeId }, + { "operatingSystem", AutoUpdater.UpdaterManager.OperatingSystemName } }; // Pass any query parameters from the registration URL into the JSON body diff --git a/Duplicati/Library/RestAPI/Database/Connection.cs b/Duplicati/Library/RestAPI/Database/Connection.cs index ac45e3edd..ea15e5188 100644 --- a/Duplicati/Library/RestAPI/Database/Connection.cs +++ b/Duplicati/Library/RestAPI/Database/Connection.cs @@ -38,8 +38,8 @@ namespace Duplicati.Server.Database { public class Connection : IDisposable { - private readonly System.Data.IDbConnection m_connection; - private readonly System.Data.IDbCommand m_errorcmd; + private readonly IDbConnection m_connection; + private readonly IDbCommand m_errorcmd; public readonly object m_lock = new object(); public const int ANY_BACKUP_ID = -1; public const int SERVER_SETTINGS_ID = -2; @@ -65,7 +65,7 @@ namespace Duplicati.Server.Database ]) .ToHashSet(StringComparer.OrdinalIgnoreCase); - public Connection(System.Data.IDbConnection connection, bool disableFieldEncryption, EncryptedFieldHelper.KeyInstance? key) + public Connection(IDbConnection connection, bool disableFieldEncryption, EncryptedFieldHelper.KeyInstance? key) { m_encryptSensitiveFields = !disableFieldEncryption; m_key = key; @@ -141,7 +141,7 @@ namespace Duplicati.Server.Database } } - public void ExecuteWithCommand(Action f) + public void ExecuteWithCommand(Action f) { lock (m_lock) using (var cmd = m_connection.CreateCommand()) @@ -214,7 +214,7 @@ namespace Duplicati.Server.Database .ToDictionary((k) => k.Key, (k) => k.Value); } - internal void SetMetadata(IDictionary values, long id, System.Data.IDbTransaction? transaction) + internal void SetMetadata(IDictionary values, long id, IDbTransaction? transaction) { lock (m_lock) using (var tr = transaction == null ? m_connection.BeginTransaction() : null) @@ -246,7 +246,7 @@ namespace Duplicati.Server.Database .ToArray(); } - internal void SetFilters(IEnumerable values, long id, System.Data.IDbTransaction? transaction = null) + internal void SetFilters(IEnumerable values, long id, IDbTransaction? transaction = null) { lock (m_lock) using (var tr = transaction == null ? m_connection.BeginTransaction() : null) @@ -279,7 +279,7 @@ namespace Duplicati.Server.Database .ToArray(); } - internal void SetSettings(IEnumerable values, long id, System.Data.IDbTransaction? transaction = null) + internal void SetSettings(IEnumerable values, long id, IDbTransaction? transaction = null) { lock (m_lock) using (var tr = transaction == null ? m_connection.BeginTransaction() : null) @@ -319,7 +319,7 @@ namespace Duplicati.Server.Database .ToArray(); } - internal void SetSources(IEnumerable values, long id, System.Data.IDbTransaction transaction) + internal void SetSources(IEnumerable values, long id, IDbTransaction transaction) { lock (m_lock) using (var tr = transaction == null ? m_connection.BeginTransaction() : null) @@ -605,7 +605,7 @@ namespace Duplicati.Server.Database for (var i = 0; i < 100; i++) { - var guess = System.IO.Path.Combine(folder, System.IO.Path.ChangeExtension(Duplicati.Library.Main.DatabaseLocator.GenerateRandomName(), ".sqlite")); + var guess = System.IO.Path.Combine(folder, System.IO.Path.ChangeExtension(CLIDatabaseLocator.GenerateRandomName(), ".sqlite")); if (!System.IO.File.Exists(guess)) { ((Backup)item).DBPath = guess; @@ -715,7 +715,7 @@ namespace Duplicati.Server.Database } } - private void AddOrUpdateSchedule(ISchedule item, System.Data.IDbTransaction tr) + private void AddOrUpdateSchedule(ISchedule item, IDbTransaction tr) { lock (m_lock) { @@ -981,7 +981,7 @@ namespace Duplicati.Server.Database .ToDictionary(x => x.Key, x => x.Last().Value); } - public void SetUISettings(string scheme, IDictionary values, System.Data.IDbTransaction? transaction = null) + public void SetUISettings(string scheme, IDictionary values, IDbTransaction? transaction = null) { lock (m_lock) using (var tr = transaction == null ? m_connection.BeginTransaction() : null) @@ -1002,7 +1002,7 @@ namespace Duplicati.Server.Database } } - public void UpdateUISettings(string scheme, IDictionary values, System.Data.IDbTransaction? transaction = null) + public void UpdateUISettings(string scheme, IDictionary values, IDbTransaction? transaction = null) { lock (m_lock) using (var tr = transaction == null ? m_connection.BeginTransaction() : null) @@ -1066,24 +1066,24 @@ namespace Duplicati.Server.Database } } - private static DateTime ConvertToDateTime(System.Data.IDataReader rd, int index) + private static DateTime ConvertToDateTime(IDataReader rd, int index) { var unixTime = ConvertToInt64(rd, index); return unixTime == 0 ? new DateTime(0) : Library.Utility.Utility.EPOCH.AddSeconds(unixTime); } - private static bool ConvertToBoolean(System.Data.IDataReader rd, int index) + private static bool ConvertToBoolean(IDataReader rd, int index) { return ConvertToInt64(rd, index) == 1; } - private static string? ConvertToString(System.Data.IDataReader rd, int index) + private static string? ConvertToString(IDataReader rd, int index) { var r = rd.GetValue(index); return r == null || r == DBNull.Value ? null : r.ToString(); } - private static long ConvertToInt64(System.Data.IDataReader rd, int index) + private static long ConvertToInt64(IDataReader rd, int index) { try { @@ -1097,20 +1097,20 @@ namespace Duplicati.Server.Database return -1; } - private static long ExecuteScalarInt64(System.Data.IDbCommand cmd, long defaultValue = -1) + private static long ExecuteScalarInt64(IDbCommand cmd, long defaultValue = -1) { using (var rd = cmd.ExecuteReader()) return rd.Read() ? ConvertToInt64(rd, 0) : defaultValue; } - private static string? ExecuteScalarString(System.Data.IDbCommand cmd) + private static string? ExecuteScalarString(IDbCommand cmd) { using (var rd = cmd.ExecuteReader()) return rd.Read() ? ConvertToString(rd, 0) : null; } - private object? ConvertToEnum(Type enumType, System.Data.IDataReader rd, int index, object? @default) + private object? ConvertToEnum(Type enumType, IDataReader rd, int index, object? @default) { try { @@ -1124,13 +1124,13 @@ namespace Duplicati.Server.Database } // Overloaded function for legacy functionality - private bool DeleteFromDb(string tablename, long id, System.Data.IDbTransaction? transaction = null) + private bool DeleteFromDb(string tablename, long id, IDbTransaction? transaction = null) { return DeleteFromDb(tablename, id, "ID", transaction); } // New function that allows to delete rows from tables with arbitrary identifier values (e.g. ID or BackupID) - private bool DeleteFromDb(string tablename, long id, string identifier, System.Data.IDbTransaction? transaction = null) + private bool DeleteFromDb(string tablename, long id, string identifier, IDbTransaction? transaction = null) { if (transaction == null) { @@ -1158,14 +1158,14 @@ namespace Duplicati.Server.Database } } - private static IEnumerable Read(System.Data.IDbCommand cmd, Func f) + private static IEnumerable Read(IDbCommand cmd, Func f) { using (var rd = cmd.ExecuteReader()) while (rd.Read()) yield return f(rd); } - private static IEnumerable Read(System.Data.IDataReader rd, Func f) + private static IEnumerable Read(IDataReader rd, Func f) { while (rd.Read()) yield return f(); @@ -1226,7 +1226,7 @@ namespace Duplicati.Server.Database }, sql, args); } - private void OverwriteAndUpdateDb(System.Data.IDbTransaction? transaction, string? deleteSql, object[]? deleteArgs, IEnumerable values, bool updateExisting) + private void OverwriteAndUpdateDb(IDbTransaction? transaction, string? deleteSql, object[]? deleteArgs, IEnumerable values, bool updateExisting) { var properties = GetORMFields(); var idfield = properties.FirstOrDefault(x => x.Name == "ID") @@ -1286,7 +1286,7 @@ namespace Duplicati.Server.Database } } - private IEnumerable ReadFromDb(Func f, string sql, params object?[]? args) + private IEnumerable ReadFromDb(Func f, string sql, params object?[]? args) { using (var cmd = m_connection.CreateCommand()) { @@ -1297,7 +1297,7 @@ namespace Duplicati.Server.Database } } - private void OverwriteAndUpdateDb(System.Data.IDbTransaction? transaction, string? deleteSql, object[]? deleteArgs, IEnumerable values, string insertSql, Func f) + private void OverwriteAndUpdateDb(IDbTransaction? transaction, string? deleteSql, object[]? deleteArgs, IEnumerable values, string insertSql, Func f) { using (var cmd = m_connection.CreateCommand()) { diff --git a/Duplicati/Library/RestAPI/Database/ServerSettings.cs b/Duplicati/Library/RestAPI/Database/ServerSettings.cs index 770c5dd39..d90d463b5 100644 --- a/Duplicati/Library/RestAPI/Database/ServerSettings.cs +++ b/Duplicati/Library/RestAPI/Database/ServerSettings.cs @@ -71,6 +71,7 @@ namespace Duplicati.Server.Database public const string TIMEZONE_OPTION = "server-timezone"; public const string PAUSED_UNTIL = "paused-until"; public const string LAST_UPDATE_CHECK_VERSION = "last-update-check-version"; + public const string ADDITIONAL_REPORT_URL = "additional-report-url"; } private readonly Dictionary settings; @@ -847,6 +848,20 @@ namespace Duplicati.Server.Database SaveSettings(); } } + + public string? AdditionalReportUrl + { + get + { + return settings[CONST.ADDITIONAL_REPORT_URL]; + } + set + { + lock (databaseConnection.m_lock) + settings[CONST.ADDITIONAL_REPORT_URL] = value; + SaveSettings(); + } + } } } diff --git a/Duplicati/Library/RestAPI/Runner.cs b/Duplicati/Library/RestAPI/Runner.cs index 2014358c8..6a8c63ffc 100644 --- a/Duplicati/Library/RestAPI/Runner.cs +++ b/Duplicati/Library/RestAPI/Runner.cs @@ -736,6 +736,7 @@ namespace Duplicati.Server backup.Metadata["TargetFilesSize"] = r.KnownFileSize.ToString(); backup.Metadata["TargetFilesCount"] = r.KnownFileCount.ToString(); + backup.Metadata["TargetFilesetsCount"] = r.KnownFilesets.ToString(); backup.Metadata["TargetSizeString"] = Duplicati.Library.Utility.Utility.FormatSizeString(r.KnownFileSize); } } @@ -919,6 +920,18 @@ namespace Duplicati.Server // The server hangs if the module is enabled as there is no console attached DisableModule("console-password-input", options); + // Patch in additional report urls + var additionalReportUrl = FIXMEGlobal.DataConnection.ApplicationSettings.AdditionalReportUrl; + if (!string.IsNullOrWhiteSpace(additionalReportUrl)) + { + options["send-http-json-urls"] = string.Join(";", + new[] { + options.GetValueOrDefault("send-http-json-urls"), + additionalReportUrl + }.Where(x => !string.IsNullOrWhiteSpace(x)) + ); + } + return options; } diff --git a/Duplicati/Library/RestAPI/Scheduler.cs b/Duplicati/Library/RestAPI/Scheduler.cs index ba7bd40cf..8940691fd 100644 --- a/Duplicati/Library/RestAPI/Scheduler.cs +++ b/Duplicati/Library/RestAPI/Scheduler.cs @@ -143,7 +143,7 @@ namespace Duplicati.Server get { lock (m_lock) - return m_schedule.ToList(); + return (m_schedule ?? []).ToList(); } } @@ -152,7 +152,7 @@ namespace Duplicati.Server /// public List WorkerQueue { - get { return (from t in m_worker.CurrentTasks where t != null select t).ToList(); } + get { return m_worker?.CurrentTasks?.Where(t => t != null)?.ToList() ?? []; } } /// diff --git a/Duplicati/Library/RestAPI/SpecialFolders.cs b/Duplicati/Library/RestAPI/SpecialFolders.cs index e60b2a05b..180810e77 100644 --- a/Duplicati/Library/RestAPI/SpecialFolders.cs +++ b/Duplicati/Library/RestAPI/SpecialFolders.cs @@ -133,7 +133,7 @@ namespace Duplicati.Server try { // PInvoke to get the download folder - TryAdd(lst, SHGetFolder.DownloadFolder, "%MY_DOWNLOADS%", "Downloads"); + TryAdd(lst, Library.Utility.SHGetFolder.DownloadFolder, "%MY_DOWNLOADS%", "Downloads"); } catch { @@ -208,49 +208,6 @@ namespace Duplicati.Server return result; } - - /// - /// Wrapper for SHGetKnownFolderPath to get the download folder on Windows - /// - [SupportedOSPlatform("windows")] - private static class SHGetFolder - { - /// - /// Gets the download folder path - /// - public static string DownloadFolder => GetKnownFolderPath(new Guid("374DE290-123F-4565-9164-39C4925E467B")); - - /// - /// SHGetKnownFolderPath function to get the folder path - /// - /// The folder GUID - /// Get folder flags - /// The access token - /// The folder path - /// The HRESULT error code - [DllImport("Shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern int SHGetKnownFolderPath( - [MarshalAs(UnmanagedType.LPStruct)] Guid rfid, - uint dwFlags, - IntPtr hToken, - out IntPtr ppszPath); - - /// - /// Gets the folder path using SHGetKnownFolderPath - /// - /// The folder GUID - /// The folder path - private static string GetKnownFolderPath(Guid folderGuid) - { - var result = SHGetKnownFolderPath(folderGuid, 0, IntPtr.Zero, out var outPath); - if (result != 0) - Marshal.ThrowExceptionForHR(result); // Throws an exception for the HRESULT error code - - var path = Marshal.PtrToStringUni(outPath); - Marshal.FreeCoTaskMem(outPath); // Free the memory allocated by SHGetKnownFolderPath - return path; - } - } } } diff --git a/Duplicati/Library/RestAPI/Strings.cs b/Duplicati/Library/RestAPI/Strings.cs index b773359f1..eed6a0ef4 100644 --- a/Duplicati/Library/RestAPI/Strings.cs +++ b/Duplicati/Library/RestAPI/Strings.cs @@ -41,6 +41,7 @@ Error message: {0}", message); } 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 LogfileCommandDescription { get { return LC.L(@"Output log information to the file given"); } } public static string LoglevelCommandDescription { get { return LC.L(@"Determine the amount of information written in the log file"); } } + public static string LogConsoleDescription { get { return LC.L(@"Output log information to the console"); } } public static string PortablemodeCommandDescription { get { return LC.L(@"Activate portable mode where the database is placed below the program executable"); } } public static string SeriousError(string message) { return LC.L(@"A serious error occurred in Duplicati: {0}", message); } public static string TearDownError(string message) { return LC.L(@"An error occurred on server tear down: {0}", message); } @@ -59,6 +60,8 @@ Error message: {0}", error); } public static string WebserverAllowedhostnamesDescription { get { return LC.L(@"The hostnames that are accepted, separated with semicolons. If any of the hostnames are ""*"", all hostnames are allowed and the hostname checking is disabled."); } } public static string PingpongkeepaliveLong { get { return LC.L(@"When running as a server, the service daemon must verify that the process is responding. If this option is enabled, the server reads stdin and writes a reply to each line read."); } } public static string PingpongkeepaliveShort { get { return LC.L(@"Enable the ping-pong responder"); } } + public static string DisableupdatecheckShort { get { return LC.L(@"Disable the automatic update check"); } } + public static string DisableupdatecheckLong { get { return LC.L(@"Use this option to disable the automatic update check. Manual update checks can still be performed."); } } public static string LogretentionLong { get { return LC.L(@"Set the time after which log data will be purged from the database."); } } public static string LogretentionShort { get { return LC.L(@"Clean up old log data"); } } public static string ServerdatafolderLong(string envname) { return LC.L(@"Duplicati needs to store a small database with all settings. Use this option to choose where the settings are stored. This option can also be set with the environment variable {0}.", envname); } @@ -73,6 +76,7 @@ Error message: {0}", error); } public static string WebserverApiOnlyDescription { get { return LC.L(@"Disable the web interface and only allow API access"); } } public static string WebserverDisableSigninTokensDescription { get { return LC.L(@"Disable the use of signin tokens"); } } public static string WebserverSpaPathsDescription { get { return LC.L(@"The relative paths that should be served as single page applications, separated with semicolons."); } } + public static string WebserverCorsOriginsDescription { get { return LC.L(@"A list of CORS origins to allow, separated with semicolons. Each origin must be a valid URL."); } } public static string WebserverTimezoneDescription { get { return LC.L(@"The timezone to use for the webserver. The timezone must be a valid timezone identifier, such as ""America/New_York"" or ""UTC"". Common three-letter abbreviations like ""CET"" are supported, but ambiguous in some cases."); } } public static string DisabledbencryptionLong { get { return LC.L(@"Use this option to disable database encryption of sensitive fields"); } } public static string DisabledbencryptionShort { get { return LC.L(@"Disable database encryption"); } } @@ -83,7 +87,7 @@ Error message: {0}", error); } public static string WindowsEventLogSourceNotFound(string source) { return LC.L(@"The Windows event log source {0} was not found. Attempting to create.", source); } public static string WindowsEventLogSourceNotCreated(string source) { return LC.L(@"The Windows Event Log was not created for: {0}, not logging to eventlog.", source); } public static string WindowsEventLogNotSupported { get { return LC.L(@"The Windows event log is not supported on this platform"); } } - public static string ServerStarted(int port) { return LC.L(@"Server has started and is listening on port {0}", port); } + public static string ServerStarted(string @interface, int port) { return LC.L(@"Server has started and is listening on {0}, port {1}", @interface, port); } public static string ServerStartedSignin(string url) { return LC.L(@"Use the following link to sign in: {0}", url); } public static string ServerCrashed(string message) { return LC.L(@"The server crashed: {0}", message); } public static string ServerStopping { get { return LC.L(@"Server is stopping, tearing down handlers"); } } diff --git a/Duplicati/Library/RestAPI/UpdatePollThread.cs b/Duplicati/Library/RestAPI/UpdatePollThread.cs index 5996c46e6..ee251f244 100644 --- a/Duplicati/Library/RestAPI/UpdatePollThread.cs +++ b/Duplicati/Library/RestAPI/UpdatePollThread.cs @@ -39,6 +39,7 @@ namespace Duplicati.Server private readonly object m_lock = new object(); private AutoResetEvent m_waitSignal; private double m_downloadProgress; + private bool m_disableChecks = false; public bool IsUpdateRequested { get; private set; } = false; @@ -56,8 +57,9 @@ namespace Duplicati.Server } } - public void Init() + public void Init(bool disableChecks) { + m_disableChecks = disableChecks; m_waitSignal = new AutoResetEvent(false); ThreadState = UpdatePollerStates.Waiting; m_thread = new Thread(Run) @@ -115,16 +117,20 @@ namespace Duplicati.Server if (nextCheck < DateTime.UtcNow || m_forceCheck) { + DateTime started = DateTime.UtcNow; + connection.ApplicationSettings.LastUpdateCheck = started; + nextCheck = connection.ApplicationSettings.NextUpdateCheck; + + // If this is not forced, and we have disabled checks, we just update the next check time + if (m_disableChecks && !m_forceCheck) + continue; + lock (m_lock) m_forceCheck = false; ThreadState = UpdatePollerStates.Checking; eventPollNotify.SignalNewEvent(); - DateTime started = DateTime.UtcNow; - connection.ApplicationSettings.LastUpdateCheck = started; - nextCheck = connection.ApplicationSettings.NextUpdateCheck; - Library.AutoUpdater.ReleaseType rt; if (!Enum.TryParse(connection.ApplicationSettings.UpdateChannel, true, out rt)) rt = Duplicati.Library.AutoUpdater.ReleaseType.Unknown; diff --git a/Duplicati/Library/SQLiteHelper/SQLiteLoader.cs b/Duplicati/Library/SQLiteHelper/SQLiteLoader.cs index 339422991..43f8d11be 100644 --- a/Duplicati/Library/SQLiteHelper/SQLiteLoader.cs +++ b/Duplicati/Library/SQLiteHelper/SQLiteLoader.cs @@ -1,30 +1,27 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. #nullable enable using System; -using System.IO; -using System.Runtime.Versioning; -using Duplicati.Library.Common; using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; @@ -217,9 +214,7 @@ namespace Duplicati.Library.SQLiteHelper { // Check if SQLite database exists before opening a connection to it. // This information is used to 'fix' permissions on a newly created file. - var fileExists = false; - if (!OperatingSystem.IsWindows()) - fileExists = File.Exists(path); + var fileExists = SystemIO.IO_OS.FileExists(path); con.ConnectionString = "Data Source=" + path; con.Open(); @@ -230,29 +225,16 @@ namespace Duplicati.Library.SQLiteHelper sqlitecon.SetConfigurationOption(System.Data.SQLite.SQLiteConfigDbOpsEnum.SQLITE_DBCONFIG_DQS_DML, false); } - - // If we are non-Windows, make the file only accessible by the current user - if ((OperatingSystem.IsMacOS() || OperatingSystem.IsLinux()) && !fileExists) - SetUnixPermissionUserRWOnly(path); - } - - /// - /// Sets the unix permission user read-write Only. - /// - /// The file to set permissions on. - /// Make sure we do not inline this, as we might eventually load Mono.Posix, which is not present on Windows - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - [SupportedOSPlatform("linux")] - [SupportedOSPlatform("macOS")] - private static void SetUnixPermissionUserRWOnly(string path) - { - var fi = PosixFile.GetUserGroupAndPermissions(path); - PosixFile.SetUserGroupAndPermissions( - path, - fi.UID, - fi.GID, - 0x180 /* FilePermissions.S_IRUSR | FilePermissions.S_IWUSR*/ - ); + // Make the file only accessible by the current user + if (fileExists) + { + if (!SystemIO.IO_OS.FileExists(SystemIO.IO_OS.PathCombine(SystemIO.IO_OS.PathGetDirectoryName(path), Util.InsecurePermissionsMarkerFile))) + SystemIO.IO_OS.FileSetPermissionUserRWOnly(path); + } + else + { + SystemIO.IO_OS.FileSetPermissionUserRWOnly(path); + } } /// diff --git a/Duplicati/Library/Snapshots/Duplicati.Library.Snapshots.csproj b/Duplicati/Library/Snapshots/Duplicati.Library.Snapshots.csproj index 2f5c08116..a166310db 100644 --- a/Duplicati/Library/Snapshots/Duplicati.Library.Snapshots.csproj +++ b/Duplicati/Library/Snapshots/Duplicati.Library.Snapshots.csproj @@ -3,10 +3,12 @@ net8.0 Copyright © 2025 Team Duplicati, MIT license + Duplicati.Library.Snapshots + @@ -14,7 +16,7 @@ - + diff --git a/Duplicati/Library/Snapshots/ISnapshotService.cs b/Duplicati/Library/Snapshots/ISnapshotService.cs index 9f61d6886..bebf5c92a 100644 --- a/Duplicati/Library/Snapshots/ISnapshotService.cs +++ b/Duplicati/Library/Snapshots/ISnapshotService.cs @@ -1,27 +1,32 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +#nullable enable using System; using System.Collections.Generic; using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Duplicati.Library.Interface; namespace Duplicati.Library.Snapshots { @@ -31,12 +36,28 @@ namespace Duplicati.Library.Snapshots public interface ISnapshotService : IDisposable { /// - /// Enumerates all files and folders in the snapshot, restricted to sources + /// Gets the source entries /// - /// Sources to enumerate - /// The callback to invoke with each found path - /// The callback used to report errors - IEnumerable EnumerateFilesAndFolders(IEnumerable sources, Utility.Utility.EnumerationFilterDelegate callback, Utility.Utility.ReportAccessError errorCallback); + IEnumerable SourceEntries { get; } + + /// + /// Enumerates the root source files and folders + /// + /// The source files and folders + IEnumerable EnumerateFilesystemEntries(); + /// + /// Enumerates the files and folders in a given folder + /// + /// The parent folder to enumerate + /// The files and folders in the parent folder + IEnumerable EnumerateFilesystemEntries(ISourceProviderEntry parent); + /// + /// Gets a filesystem entry for a given path + /// + /// The path to get + /// True if the path is a folder + /// The filesystem entry + ISourceProviderEntry? GetFilesystemEntry(string path, bool isFolder); /// /// Gets the last write time of a given file in UTC @@ -59,6 +80,14 @@ namespace Duplicati.Library.Snapshots /// An open filestream that can be read and seeked Stream OpenRead(string localPath); + /// + /// Opens a file for reading + /// + /// The full path to the file in non-snapshot format + /// A token that can be used to cancel the operation + /// An open filestream that can be read and seeked + Task OpenReadAsync(string localPath, CancellationToken cancellationToken); + /// /// Returns the size of a file /// @@ -79,29 +108,28 @@ namespace Duplicati.Library.Snapshots /// The file attributes /// The file or folder to examine FileAttributes GetAttributes(string localPath); - + /// /// Gets the metadata for the given file or folder /// /// The metadata for the given file or folder /// The file or folder to examine /// A flag indicating if the target is a symlink - /// A flag indicating if a symlink should be followed - Dictionary GetMetadata(string localPath, bool isSymlink, bool followSymlink); - + Dictionary GetMetadata(string localPath, bool isSymlink); + /// /// Gets a value indicating if the path points to a block device /// /// true if this instance is a block device; otherwise, false. /// The file or folder to examine bool IsBlockDevice(string localPath); - + /// /// Gets a unique hardlink target ID /// /// The hardlink ID /// The file or folder to examine - string HardlinkTargetID(string localPath); + string? HardlinkTargetID(string localPath); /// /// Converts a snapshot path to a local path @@ -130,17 +158,5 @@ namespace Duplicati.Library.Snapshots /// The local path /// True if folder exists, false otherwise bool DirectoryExists(string localFolderPath); - - /// - /// Tests if a file or folder exists in the snapshot - /// - /// The local path - /// True if file or folder exists, false otherwise - bool PathExists(string localFileOrFolderPath); - - /// - /// Is true if the actual service is a real snapshot service - /// - bool IsSnapshot { get; } } } diff --git a/Duplicati/Library/Snapshots/LinuxSnapshot.cs b/Duplicati/Library/Snapshots/LinuxSnapshot.cs index f49044f04..83b786ddc 100644 --- a/Duplicati/Library/Snapshots/LinuxSnapshot.cs +++ b/Duplicati/Library/Snapshots/LinuxSnapshot.cs @@ -1,31 +1,33 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Runtime.Versioning; using System.Text; using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; namespace Duplicati.Library.Snapshots { @@ -40,7 +42,7 @@ namespace Duplicati.Library.Snapshots [SupportedOSPlatform("macOS")] public sealed class LinuxSnapshot : SnapshotBase { - /// + /// /// The tag used for logging messages /// public static readonly string LOGTAG = Logging.Log.LogTagFromType(typeof(WindowsSnapshot)); @@ -59,7 +61,9 @@ namespace Duplicati.Library.Snapshots /// Constructs a new snapshot module using LVM /// /// The list of folders to create snapshots for - public LinuxSnapshot(IEnumerable sources) + /// A flag indicating if symlinks should be followed + public LinuxSnapshot(IEnumerable sources, bool followSymlinks) + : base(followSymlinks) { try { @@ -79,7 +83,7 @@ namespace Duplicati.Library.Snapshots m_entries.Add(new KeyValuePair(path, snap)); } - m_snapShots = new List(snaps.Values); + m_snapShots = [.. snaps.Values]; // We have all the snapshots that we need, lets activate them foreach (var snap in m_snapShots) @@ -94,15 +98,35 @@ namespace Duplicati.Library.Snapshots { Dispose(); } - catch (Exception ex) + catch (Exception ex) { - Logging.Log.WriteVerboseMessage(LOGTAG, "SnapshotCleanupError", ex, "Failed to clean up after error"); + Logging.Log.WriteVerboseMessage(LOGTAG, "SnapshotCleanupError", ex, "Failed to clean up after error"); } throw; } } + /// + /// Gets the source folders + /// + public override IEnumerable SourceEntries => m_entries.Select(x => x.Key); + + /// + /// Enumerates the root source files and folders + /// + /// The source files and folders + public override IEnumerable EnumerateFilesystemEntries() + { + foreach (var sourceEntry in SourceEntries) + { + if (DirectoryExists(sourceEntry) || sourceEntry.EndsWith(System.IO.Path.DirectorySeparatorChar)) + yield return new SnapshotSourceFileEntry(this, Util.AppendDirSeparator(sourceEntry), true, true); + else + yield return new SnapshotSourceFileEntry(this, sourceEntry, false, true); + } + } + /// protected override void Dispose(bool disposing) { @@ -111,10 +135,10 @@ namespace Duplicati.Library.Snapshots if (disposing) { // Attempt to clean out as many as possible - foreach(var s in m_snapShots) + foreach (var s in m_snapShots) { try { s.Dispose(); } - catch (Exception ex) { Logging.Log.WriteVerboseMessage(LOGTAG, "SnapshotCloseError", ex, "Failed to close a snapshot"); } + catch (Exception ex) { Logging.Log.WriteVerboseMessage(LOGTAG, "SnapshotCloseError", ex, "Failed to close a snapshot"); } } } @@ -466,7 +490,7 @@ namespace Duplicati.Library.Snapshots /// The symlink target public override string GetSymlinkTarget(string localPath) { - return SystemIO.IO_SYS.GetSymlinkTarget(ConvertToSnapshotPath(localPath)); + return SystemIO.IO_OS.GetSymlinkTarget(ConvertToSnapshotPath(localPath)); } /// @@ -475,10 +499,9 @@ namespace Duplicati.Library.Snapshots /// The metadata for the given file or folder /// The file or folder to examine /// A flag indicating if the target is a symlink - /// A flag indicating if a symlink should be followed - public override Dictionary GetMetadata(string localPath, bool isSymlink, bool followSymlink) + public override Dictionary GetMetadata(string localPath, bool isSymlink) { - return SystemIO.IO_SYS.GetMetadata(ConvertToSnapshotPath(localPath), isSymlink, followSymlink); + return SystemIO.IO_OS.GetMetadata(ConvertToSnapshotPath(localPath), isSymlink, FollowSymlinks); } /// @@ -500,8 +523,8 @@ namespace Duplicati.Library.Snapshots default: return true; } - } - catch + } + catch { if (!System.IO.File.Exists(SystemIOLinux.NormalizePath(localPath))) return false; @@ -509,7 +532,7 @@ namespace Duplicati.Library.Snapshots throw; } } - + /// /// Gets a unique hardlink target ID /// @@ -518,10 +541,10 @@ namespace Duplicati.Library.Snapshots public override string HardlinkTargetID(string localPath) { var snapshotPath = ConvertToSnapshotPath(localPath); - + if (PosixFile.GetHardlinkCount(snapshotPath) <= 1) return null; - + return PosixFile.GetInodeTargetID(snapshotPath); } @@ -537,9 +560,6 @@ namespace Duplicati.Library.Snapshots return FindSnapshotByLocalPath(localPath).ConvertToSnapshotPath(localPath); } - /// - public override bool IsSnapshot => true; - #endregion } } diff --git a/Duplicati/Library/Snapshots/NoSnapshotLinux.cs b/Duplicati/Library/Snapshots/NoSnapshotLinux.cs index f1601169b..de21cec98 100644 --- a/Duplicati/Library/Snapshots/NoSnapshotLinux.cs +++ b/Duplicati/Library/Snapshots/NoSnapshotLinux.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -25,6 +25,7 @@ using System.IO; using System.Runtime.InteropServices; using System.Runtime.Versioning; using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; using Microsoft.Win32.SafeHandles; namespace Duplicati.Library.Snapshots @@ -65,15 +66,43 @@ namespace Duplicati.Library.Snapshots /// private readonly bool m_ignoreAdvisoryLocks; + /// + /// The list of folders to create snapshots of + /// + private readonly IEnumerable m_sources; + /// /// Initializes a new instance of the class. /// + /// The list of sources to create snapshots of /// A flag indicating if advisory locks should be ignored - public NoSnapshotLinux(bool ignoreAdvisoryLocks) + /// A flag indicating if symlinks should be followed + public NoSnapshotLinux(IEnumerable sources, bool ignoreAdvisoryLocks, bool followSymlinks) + : base(followSymlinks) { + m_sources = sources; m_ignoreAdvisoryLocks = ignoreAdvisoryLocks; } + /// + /// Gets the source folders + /// + public override IEnumerable SourceEntries => m_sources; + + /// + /// Enumerates the root source files and folders + /// + /// The source files and folders + public override IEnumerable EnumerateFilesystemEntries() + { + foreach (var folder in m_sources) + { + if (DirectoryExists(folder) || folder.EndsWith(Path.DirectorySeparatorChar)) + yield return new SnapshotSourceFileEntry(this, Util.AppendDirSeparator(folder), true, true); + else + yield return new SnapshotSourceFileEntry(this, folder, false, true); + } + } /// /// Returns the symlink target if the entry is a symlink, and null otherwise @@ -82,7 +111,7 @@ namespace Duplicati.Library.Snapshots /// The symlink target public override string GetSymlinkTarget(string localPath) { - return SystemIO.IO_SYS.GetSymlinkTarget(localPath); + return SystemIO.IO_OS.GetSymlinkTarget(localPath); } /// @@ -91,10 +120,9 @@ namespace Duplicati.Library.Snapshots /// The metadata for the given file or folder /// The file or folder to examine /// A flag indicating if the target is a symlink - /// A flag indicating if a symlink should be followed - public override Dictionary GetMetadata(string localPath, bool isSymlink, bool followSymlink) + public override Dictionary GetMetadata(string localPath, bool isSymlink) { - return SystemIO.IO_SYS.GetMetadata(localPath, isSymlink, followSymlink); + return SystemIO.IO_OS.GetMetadata(localPath, isSymlink, FollowSymlinks); } /// diff --git a/Duplicati/Library/Snapshots/NoSnapshotWindows.cs b/Duplicati/Library/Snapshots/NoSnapshotWindows.cs index f3a3c7184..aa8c9a5ac 100644 --- a/Duplicati/Library/Snapshots/NoSnapshotWindows.cs +++ b/Duplicati/Library/Snapshots/NoSnapshotWindows.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -24,6 +24,7 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.Versioning; using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; namespace Duplicati.Library.Snapshots { @@ -33,102 +34,116 @@ namespace Duplicati.Library.Snapshots [SupportedOSPlatform("windows")] public sealed class NoSnapshotWindows : SnapshotBase { + /// + /// The list of folders to create snapshots of + /// + private readonly IEnumerable m_sources; + + /// + /// Initializes a new instance of the class. + /// + /// The list of entries to create snapshots of + /// A flag indicating if symlinks should be followed + public NoSnapshotWindows(IEnumerable sources, bool followSymlinks) + : base(followSymlinks) + { + m_sources = sources; + } + /// /// Returns the symlink target if the entry is a symlink, and null otherwise /// /// The file or folder to examine /// The symlink target public override string GetSymlinkTarget(string localPath) - { - return SystemIO.IO_WIN.GetSymlinkTarget(localPath); - } + => SystemIO.IO_OS.GetSymlinkTarget(localPath); /// /// Gets the attributes for the given file or folder /// /// The file attributes /// The file or folder to examine - public override System.IO.FileAttributes GetAttributes (string localPath) - { - return SystemIO.IO_WIN.GetFileAttributes(localPath); - } + public override System.IO.FileAttributes GetAttributes(string localPath) + => SystemIO.IO_OS.GetFileAttributes(localPath); /// /// Returns the size of a file /// /// The full path to the file in non-snapshot format /// The length of the file - public override long GetFileSize (string localPath) - { - return SystemIO.IO_WIN.FileLength(localPath); - } + public override long GetFileSize(string localPath) + => SystemIO.IO_OS.FileLength(localPath); /// - /// Enumerates all files and folders in the snapshot, restricted to sources + /// Gets the source folders /// - /// Sources to enumerate - /// The callback to invoke with each found path - /// The callback used to report errors - public override IEnumerable EnumerateFilesAndFolders(IEnumerable sources, Utility.Utility.EnumerationFilterDelegate callback, Utility.Utility.ReportAccessError errorCallback) + public override IEnumerable SourceEntries + => m_sources; + + /// + /// Enumerates the root source files and folders + /// + /// The source files and folders + public override IEnumerable EnumerateFilesystemEntries() { - // For Windows, ensure we don't store paths with extended device path prefixes (i.e., @"\\?\" or @"\\?\UNC\") - return base.EnumerateFilesAndFolders(sources.Select(SystemIOWindows.RemoveExtendedDevicePathPrefix), callback, errorCallback); + foreach (var folder in m_sources.Select(SystemIOWindows.RemoveExtendedDevicePathPrefix)) + { + if (DirectoryExists(folder) || folder.EndsWith(System.IO.Path.DirectorySeparatorChar)) + yield return new SnapshotSourceFileEntry(this, Util.AppendDirSeparator(folder), true, true); + else + yield return new SnapshotSourceFileEntry(this, folder, false, true); + } } + /// /// Gets the last write time of a given file in UTC /// /// The full path to the file in non-snapshot format /// The last write time of the file - public override DateTime GetLastWriteTimeUtc (string localPath) - { - return SystemIO.IO_WIN.FileGetLastWriteTimeUtc(localPath); - } + public override DateTime GetLastWriteTimeUtc(string localPath) + => SystemIO.IO_OS.FileGetLastWriteTimeUtc(localPath); /// /// Gets the last write time of a given file in UTC /// /// The full path to the file in non-snapshot format /// The last write time of the file - public override DateTime GetCreationTimeUtc (string localPath) - { - return SystemIO.IO_WIN.FileGetCreationTimeUtc(localPath); - } + public override DateTime GetCreationTimeUtc(string localPath) + => SystemIO.IO_OS.FileGetCreationTimeUtc(localPath); /// /// Opens a file for reading /// /// The full path to the file in non-snapshot format /// An open filestream that can be read - public override System.IO.Stream OpenRead (string localPath) - { - return SystemIO.IO_WIN.FileOpenRead(localPath); - } + public override System.IO.Stream OpenRead(string localPath) + => SystemIO.IO_OS.FileOpenRead(localPath); /// /// Lists all files in the given folder /// /// All folders found in the folder /// The folder to examinate - protected override string[] ListFiles (string localFolderPath) + protected override string[] ListFiles(string localFolderPath) { - string[] tmp = SystemIO.IO_WIN.GetFiles(localFolderPath); + string[] tmp = SystemIO.IO_OS.GetFiles(localFolderPath); string[] res = new string[tmp.Length]; - for(int i = 0; i < tmp.Length; i++) + for (int i = 0; i < tmp.Length; i++) res[i] = SystemIOWindows.RemoveExtendedDevicePathPrefix(tmp[i]); return res; } - + /// /// Lists all folders in the given folder /// /// All folders found in the folder /// The folder to examinate - protected override string[] ListFolders (string localFolderPath) + protected override string[] ListFolders(string localFolderPath) { - string[] tmp = SystemIO.IO_WIN.GetDirectories(SystemIOWindows.AddExtendedDevicePathPrefix(localFolderPath)); + string[] tmp = SystemIO.IO_OS.GetDirectories(SystemIOWindows.AddExtendedDevicePathPrefix(localFolderPath)); string[] res = new string[tmp.Length]; for (int i = 0; i < tmp.Length; i++) res[i] = SystemIOWindows.RemoveExtendedDevicePathPrefix(tmp[i]); @@ -142,11 +157,8 @@ namespace Duplicati.Library.Snapshots /// The metadata for the given file or folder /// The file or folder to examine /// A flag indicating if the target is a symlink - /// A flag indicating if a symlink should be followed - public override Dictionary GetMetadata(string localPath, bool isSymlink, bool followSymlink) - { - return SystemIO.IO_WIN.GetMetadata(localPath, isSymlink, followSymlink); - } + public override Dictionary GetMetadata(string localPath, bool isSymlink) + => SystemIO.IO_OS.GetMetadata(localPath, isSymlink, FollowSymlinks); /// public override string ConvertToLocalPath(string snapshotPath) @@ -155,11 +167,9 @@ namespace Duplicati.Library.Snapshots } /// - public override string ConvertToSnapshotPath(string localPath) - { + public override string ConvertToSnapshotPath(string localPath) => // For Windows, ensure we don't store paths with extended device path prefixes (i.e., @"\\?\" or @"\\?\UNC\") - return SystemIOWindows.RemoveExtendedDevicePathPrefix(localPath); - } + SystemIOWindows.RemoveExtendedDevicePathPrefix(localPath); } } diff --git a/Duplicati/Library/Snapshots/Program.cs b/Duplicati/Library/Snapshots/Program.cs index 38f7f1fa1..8e48df343 100644 --- a/Duplicati/Library/Snapshots/Program.cs +++ b/Duplicati/Library/Snapshots/Program.cs @@ -1,28 +1,28 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; using Duplicati.Library.AutoUpdater; -using Duplicati.Library.Common; +using Duplicati.Library.Utility; namespace Duplicati.Library.Snapshots { @@ -72,7 +72,7 @@ namespace Duplicati.Library.Snapshots if (args.Count == 0) args = new List { System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location) }; - if (args.Count != 1) + if (args.Count != 1 || HelpOptionExtensions.IsArgumentAnyHelpString(args)) { Console.WriteLine(@$"Usage: {PackageHelper.GetExecutableName(PackageHelper.NamedExecutable.Snapshots)} [test-folder] @@ -108,7 +108,7 @@ Where is the folder where files will be locked/created etc"); Console.WriteLine("Creating snapshot for folder: {0}", args[0]); Console.WriteLine("If this fails, try to run as " + ((OperatingSystem.IsMacOS() || OperatingSystem.IsLinux()) ? "root" : "Administrator")); - using (ISnapshotService snapshot = SnapshotUtility.CreateSnapshot(new[] { args[0] }, options)) + using (ISnapshotService snapshot = SnapshotUtility.CreateSnapshot(new[] { args[0] }, options, false)) { Console.WriteLine("Attempting to read locked file via snapshot"); try diff --git a/Duplicati/Library/Snapshots/SnapshotBase.cs b/Duplicati/Library/Snapshots/SnapshotBase.cs index f5d6da147..0bacef4af 100644 --- a/Duplicati/Library/Snapshots/SnapshotBase.cs +++ b/Duplicati/Library/Snapshots/SnapshotBase.cs @@ -20,9 +20,11 @@ // DEALINGS IN THE SOFTWARE. using System; -using System.Linq; using System.Collections.Generic; using System.IO; +using Duplicati.Library.Interface; +using System.Threading.Tasks; +using System.Threading; using Duplicati.Library.Common.IO; namespace Duplicati.Library.Snapshots @@ -40,19 +42,63 @@ namespace Duplicati.Library.Snapshots Dispose(false); } - #region ISnapshotService - /// - /// Enumerates all files and folders in the shadow copy + /// Initializes a new instance of the class. /// - /// Source paths to enumerate - /// The callback to invoke with each found path - /// The callback used to report errors - public virtual IEnumerable EnumerateFilesAndFolders(IEnumerable sources, Utility.Utility.EnumerationFilterDelegate callback, Utility.Utility.ReportAccessError errorCallback) + /// A flag indicating if symlinks should be followed + protected SnapshotBase(bool followSymlinks) { - return sources.SelectMany(s => Utility.Utility.EnumerateFileSystemEntries(s, callback, ListFolders, ListFiles, GetAttributes, errorCallback)); + FollowSymlinks = followSymlinks; } - + + #region ISnapshotService + + protected bool FollowSymlinks { get; } + + /// + /// Gets the source entries + /// + public abstract IEnumerable SourceEntries { get; } + + /// + /// Enuemrates the root source files and folders + /// + /// The source files and folders + public abstract IEnumerable EnumerateFilesystemEntries(); + + /// + /// Gets a filesystem entry for a given path + /// + /// The path to get the entry for + /// A flag indicating if the path is a folder + /// The filesystem entry + public virtual ISourceProviderEntry GetFilesystemEntry(string path, bool isFolder) + { + if (isFolder && !DirectoryExists(path)) + return null; + else if (!isFolder && !FileExists(path)) + return null; + + return new SnapshotSourceFileEntry(this, isFolder ? Util.AppendDirSeparator(path) : path, isFolder, false); + } + + /// + /// Enumerates files and folders in the given folder + /// + /// Source to enumerate + /// The files and folders in the given folder + public virtual IEnumerable EnumerateFilesystemEntries(ISourceProviderEntry source) + { + if (source.IsFolder) + { + foreach (var f in ListFiles(source.Path)) + yield return new SnapshotSourceFileEntry(this, f, false, false); + + foreach (var d in ListFolders(source.Path)) + yield return new SnapshotSourceFileEntry(this, Util.AppendDirSeparator(d), true, false); + } + } + /// /// Gets the last write time of a given file in UTC /// @@ -72,7 +118,7 @@ namespace Duplicati.Library.Snapshots { return File.GetCreationTimeUtc(localPath); } - + /// /// Opens a file for reading /// @@ -83,6 +129,15 @@ namespace Duplicati.Library.Snapshots return File.Open(localPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); } + /// + /// Opens a file for reading + /// + /// The full path to the file in non-snapshot format + /// A token that can be used to cancel the operation + /// An open filestream that can be read + public virtual Task OpenReadAsync(string localPath, CancellationToken cancellationToken) + => Task.FromResult(OpenRead(localPath)); + /// /// Returns the size of a file /// @@ -116,9 +171,8 @@ namespace Duplicati.Library.Snapshots /// The metadata for the given file or folder /// The file or folder to examine /// A flag indicating if the target is a symlink - /// A flag indicating if a symlink should be followed - public abstract Dictionary GetMetadata(string localPath, bool isSymlink, bool followSymlink); - + public abstract Dictionary GetMetadata(string localPath, bool isSymlink); + /// /// Gets a value indicating if the path points to a block device /// @@ -128,7 +182,7 @@ namespace Duplicati.Library.Snapshots { return false; } - + /// /// Gets a unique hardlink target ID /// @@ -157,15 +211,6 @@ namespace Duplicati.Library.Snapshots return Directory.Exists(ConvertToSnapshotPath(localFolderPath)); } - /// - public virtual bool PathExists(string localFileOrFolderPath) - { - return FileExists(localFileOrFolderPath) || DirectoryExists(localFileOrFolderPath); - } - - /// - public virtual bool IsSnapshot => false; - #endregion /// @@ -198,7 +243,7 @@ namespace Duplicati.Library.Snapshots // Now suppress finalization for this object, since we've already handled our resource cleanup tasks GC.SuppressFinalize(this); - } + } #endregion diff --git a/Duplicati/Library/Snapshots/SnapshotProvider.cs b/Duplicati/Library/Snapshots/SnapshotProvider.cs new file mode 100644 index 000000000..c1e8984fa --- /dev/null +++ b/Duplicati/Library/Snapshots/SnapshotProvider.cs @@ -0,0 +1,30 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +namespace Duplicati.Library.Snapshots +{ + public enum SnapshotProvider + { + AlphaVSS, + Wmic, + LVM + } +} diff --git a/Duplicati/Library/Snapshots/SnapshotSourceFileEntry.cs b/Duplicati/Library/Snapshots/SnapshotSourceFileEntry.cs new file mode 100644 index 000000000..7fc9cae22 --- /dev/null +++ b/Duplicati/Library/Snapshots/SnapshotSourceFileEntry.cs @@ -0,0 +1,143 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Duplicati.Library.Interface; + +namespace Duplicati.Library.Snapshots; + + +/// +/// A snapshot-based source file entry +/// +/// The snapshot service +/// The path of the file +/// True if the entry is a folder, false otherwise +/// True if the entry is the root entry, false otherwise +public class SnapshotSourceFileEntry(ISnapshotService service, string path, bool isFolder, bool isRoot) : ISourceProviderEntry +{ + /// + /// The symlink target + /// + private string? symlinkTarget; + /// + /// The file attributes + /// + private FileAttributes? attributes; + /// + /// Gets the symlink target or null if the entry is not a symlink + /// + public string? SymlinkTarget + { + get + { + if (symlinkTarget != null) + return symlinkTarget; + + if (!service.IsSymlink(path, Attributes)) + return null; + + return symlinkTarget = service.GetSymlinkTarget(path); + } + } + + /// + /// True if the entry is a symlink, false otherwise + /// + public bool IsSymlink => SymlinkTarget != null; + + /// + /// Gets the entry attributes + /// + public FileAttributes Attributes + { + get + { + if (attributes != null) + return attributes.Value; + + attributes = service.GetAttributes(path); + return attributes.Value; + } + } + + /// + /// Gets the minor metadata for the file + /// + public Dictionary MinorMetadata => service.GetMetadata(path, IsSymlink); + + /// + /// Gets a value indicating if the entry is a block device + /// + public bool IsBlockDevice => service.IsBlockDevice(path); + + /// + /// Gets a value indicating if the entry is a character device + /// + public bool IsCharacterDevice => false; + + /// + /// Gets a value indicating if the entry is an alternate stream + /// + public bool IsAlternateStream => false; + + /// + /// Gets the hardlink target ID, or null if the entry is not a hardlink + /// + public string? HardlinkTargetId => service.HardlinkTargetID(path); + + /// + public bool IsFolder => isFolder; + + /// + public bool IsMetaEntry => false; + /// + public bool IsRootEntry => isRoot; + + /// + public DateTime CreatedUtc => service.GetCreationTimeUtc(path); + + /// + public DateTime LastModificationUtc => service.GetLastWriteTimeUtc(path); + + /// + public string Path => path; + + /// + public long Size => IsFolder ? -1 : service.GetFileSize(path); + + /// + public Task OpenRead(CancellationToken cancellationToken) => service.OpenReadAsync(path, cancellationToken); + + /// + public Task OpenMetadataRead(CancellationToken cancellationToken) => Task.FromResult(new MemoryStream(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(MinorMetadata))); + /// + public IAsyncEnumerable Enumerate(CancellationToken cancellationToken) => service.EnumerateFilesystemEntries(this).ToAsyncEnumerable(); + /// + public Task FileExists(string filename, CancellationToken cancellationToken) => Task.FromResult(service.FileExists(System.IO.Path.Combine(path, filename))); +} \ No newline at end of file diff --git a/Duplicati/Library/Snapshots/SnapshotUtility.cs b/Duplicati/Library/Snapshots/SnapshotUtility.cs index 7ea314b14..10e9d6c6e 100644 --- a/Duplicati/Library/Snapshots/SnapshotUtility.cs +++ b/Duplicati/Library/Snapshots/SnapshotUtility.cs @@ -1,30 +1,29 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; using System.IO; using System.Runtime.Versioning; -using Duplicati.Library.Common; using Duplicati.Library.Common.IO; namespace Duplicati.Library.Snapshots @@ -37,24 +36,42 @@ namespace Duplicati.Library.Snapshots /// /// Loads a snapshot implementation for the current OS /// - /// The list of folders to create snapshots of + /// The list of paths to create snapshots of /// A set of commandline options + /// Whether to follow symlinks /// The ISnapshotService implementation - public static ISnapshotService CreateSnapshot(IEnumerable folders, Dictionary options) + public static ISnapshotService CreateSnapshot(IEnumerable paths, Dictionary options, bool followSymlinks) { - if (OperatingSystem.IsMacOS() || OperatingSystem.IsLinux()) + if (OperatingSystem.IsLinux()) { - return CreateLinuxSnapshot(folders); + return CreateLinuxSnapshot(paths, followSymlinks); } else if (OperatingSystem.IsWindows()) { - return CreateWindowsSnapshot(folders, options); + return CreateWindowsSnapshot(paths, options, followSymlinks); } else { throw new NotSupportedException("Unsupported Operating System"); } - + } + + /// + /// Creates a snapshot implementation that does not use snapshots (i.e., regular file access) + /// + /// The list of paths to create snapshots of + /// Flag to ignore advisory locking + /// Whether to follow symlinks + /// The ISnapshotService implementation + public static ISnapshotService CreateNoSnapshot(IEnumerable paths, bool ignoreAdvisoryLocking, bool followSymlinks) + { + if (OperatingSystem.IsMacOS() || OperatingSystem.IsLinux()) + return new NoSnapshotLinux(paths, ignoreAdvisoryLocking, followSymlinks); + else if (OperatingSystem.IsWindows()) + return new NoSnapshotWindows(paths, followSymlinks); + else + throw new NotSupportedException("Unsupported Operating System"); + } // The two loader methods below guard against the type system attempting to load types @@ -65,13 +82,14 @@ namespace Duplicati.Library.Snapshots /// Loads a snapshot implementation for Linux /// /// The list of folders to create snapshots of + /// Whether to follow symlinks /// The ISnapshotService implementation [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] [SupportedOSPlatform("linux")] [SupportedOSPlatform("macOS")] - private static ISnapshotService CreateLinuxSnapshot(IEnumerable folders) + private static ISnapshotService CreateLinuxSnapshot(IEnumerable folders, bool followSymlinks) { - return new LinuxSnapshot(folders); + return new LinuxSnapshot(folders, followSymlinks); } /// @@ -79,23 +97,13 @@ namespace Duplicati.Library.Snapshots /// /// The list of folders to create snapshots of /// A set of commandline options + /// Whether to follow symlinks /// The ISnapshotService implementation [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] [SupportedOSPlatform("windows")] - private static ISnapshotService CreateWindowsSnapshot(IEnumerable folders, Dictionary options) + private static ISnapshotService CreateWindowsSnapshot(IEnumerable folders, Dictionary options, bool followSymlinks) { - return new WindowsSnapshot(folders, options); - } - - /// - /// Extension method for ISnapshotService which determines whether the given path is a symlink. - /// - /// ISnapshotService implementation - /// File or folder path - /// Whether the path is a symlink - public static bool IsSymlink(this ISnapshotService snapshot, string path) - { - return snapshot.IsSymlink(path, snapshot.GetAttributes(path)); + return new WindowsSnapshot(folders, options, followSymlinks); } /// diff --git a/Duplicati/Library/Snapshots/USNJournal.cs b/Duplicati/Library/Snapshots/USN/USNJournal.cs similarity index 96% rename from Duplicati/Library/Snapshots/USNJournal.cs rename to Duplicati/Library/Snapshots/USN/USNJournal.cs index 199a4d745..73346ae59 100644 --- a/Duplicati/Library/Snapshots/USNJournal.cs +++ b/Duplicati/Library/Snapshots/USN/USNJournal.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. @@ -31,7 +31,7 @@ using System.Runtime.Versioning; using Duplicati.Library.Common.IO; using Microsoft.Win32.SafeHandles; -namespace Duplicati.Library.Snapshots +namespace Duplicati.Library.Snapshots.USN { /// /// Class that encapsulates USN journal access to a single volume @@ -178,7 +178,7 @@ namespace Duplicati.Library.Snapshots if (path == null) throw new Exception(Strings.USNHelper.UnexpectedPathFormat); - return SystemIO.IO_WIN.GetPathRoot(path); + return SystemIO.IO_OS.GetPathRoot(path); } public static string GetDeviceNameFromPath(string path) @@ -509,7 +509,7 @@ namespace Duplicati.Library.Snapshots var path = m_volume; foreach (var r in pathList) { - path = SystemIO.IO_WIN.PathCombine(path, r.FileName); + path = SystemIO.IO_OS.PathCombine(path, r.FileName); } if (rec.UsnRecord.FileAttributes.HasFlag(Win32USN.FileAttributes.Directory)) diff --git a/Duplicati/Library/Snapshots/UsnJournalService.cs b/Duplicati/Library/Snapshots/USN/UsnJournalService.cs similarity index 66% rename from Duplicati/Library/Snapshots/UsnJournalService.cs rename to Duplicati/Library/Snapshots/USN/UsnJournalService.cs index 419dc802e..cc5963199 100644 --- a/Duplicati/Library/Snapshots/UsnJournalService.cs +++ b/Duplicati/Library/Snapshots/USN/UsnJournalService.cs @@ -19,6 +19,7 @@ // 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; @@ -29,8 +30,10 @@ using System.Threading; using Duplicati.Library.Interface; using Duplicati.Library.Common.IO; using Duplicati.Library.Utility; +using System.Threading.Tasks; +using System.Runtime.CompilerServices; -namespace Duplicati.Library.Snapshots +namespace Duplicati.Library.Snapshots.USN { [SupportedOSPlatform("windows")] public class UsnJournalService @@ -40,30 +43,39 @@ namespace Duplicati.Library.Snapshots /// private static readonly string FILTER_LOGTAG = Logging.Log.LogTagFromType(typeof(UsnJournalService)); + /// + /// The snapshot service + /// private readonly ISnapshotService m_snapshot; - private readonly IEnumerable m_sources; + /// + /// The volume data dictionary + /// private readonly Dictionary m_volumeDataDict; + /// + /// The cancellation token + /// private readonly CancellationToken m_token; /// /// Constructor. /// - /// Sources to filter /// /// Emit filter /// /// /// Journal-data of previous fileset /// - public UsnJournalService(IEnumerable sources, ISnapshotService snapshot, IFilter emitFilter, FileAttributes fileAttributeFilter, + public UsnJournalService(ISnapshotService snapshot, IFilter emitFilter, FileAttributes fileAttributeFilter, long skipFilesLargerThan, IEnumerable prevJournalData, CancellationToken token) { - m_sources = sources; m_snapshot = snapshot; m_volumeDataDict = Initialize(emitFilter, fileAttributeFilter, skipFilesLargerThan, prevJournalData); m_token = token; } + /// + /// The volume data lists for the mapped root drives + /// public IEnumerable VolumeDataList => m_volumeDataDict.Select(e => e.Value); /// @@ -84,8 +96,8 @@ namespace Duplicati.Library.Snapshots // get hash identifying current source filter / sources configuration var configHash = Utility.Utility.ByteArrayAsHexString(MD5HashHelper.GetHash(new string[] { - emitFilter == null ? string.Empty : emitFilter.ToString(), - string.Join("; ", m_sources), + emitFilter?.ToString() ?? string.Empty, + string.Join("; ", m_snapshot.SourceEntries), fileAttributeFilter.ToString(), skipFilesLargerThan.ToString() })); @@ -94,7 +106,7 @@ namespace Duplicati.Library.Snapshots var journalDataDict = prevJournalData.ToDictionary(data => data.Volume); // iterate over volumes - foreach (var sourcesPerVolume in SortByVolume(m_sources)) + foreach (var sourcesPerVolume in SortByVolume(m_snapshot.SourceEntries)) { Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "UsnInitialize", "Reading USN journal for volume: {0}", sourcesPerVolume.Key); @@ -180,8 +192,7 @@ namespace Duplicati.Library.Snapshots // Since that folder is going to be fully scanned, those files can be removed. // Note: it would be wrong to use the result from step 2. as the folder list! The entries removed // between 1. and 2. are *excluded* folders, and files below them are to be *excluded*, too. - volumeData.Files = - new HashSet(Utility.Utility.GetFilesNotInFolders(changedFiles, volumeData.Folders)); + volumeData.Files = [.. Utility.Utility.GetFilesNotInFolders(changedFiles, volumeData.Folders)]; // Record success for volume volumeData.IsFullScan = false; @@ -213,26 +224,69 @@ namespace Duplicati.Library.Snapshots return result; } + /// + /// Returns all sources that should be fully scanned + /// + /// Cancellation token + /// Filtered sources + public async IAsyncEnumerable GetFullScanSources([EnumeratorCancellation] CancellationToken token) + { + // Make the method async enumerable + await Task.CompletedTask; + + foreach (var volumeData in m_volumeDataDict.Values.Where(x => x.IsFullScan)) + { + if (volumeData.Folders != null) + { + foreach (var folderPath in volumeData.Folders) + { + if (token.IsCancellationRequested) + yield break; + + var folder = m_snapshot.GetFilesystemEntry(folderPath, true); + if (folder != null) + yield return folder; + else + Logging.Log.WriteWarningMessage(FILTER_LOGTAG, "FolderLookupError", null, "No entry while locating source folder {0}", folderPath); + } + } + + if (volumeData.Files != null) + { + foreach (var filePath in volumeData.Files) + { + if (token.IsCancellationRequested) + yield break; + + var file = m_snapshot.GetFilesystemEntry(filePath, false); + if (file != null) + yield return file; + else + Logging.Log.WriteWarningMessage(FILTER_LOGTAG, "FolderLookupError", null, "No entry while locating source file {0}", filePath); + } + } + } + } + + /// /// Filters sources, returning sub-set having been modified since last /// change, as specified by journalData. /// /// Filter callback to exclude filtered items + /// Cancellation token /// Filtered sources - public IEnumerable GetModifiedSources(Utility.Utility.EnumerationFilterDelegate filter) + public async IAsyncEnumerable GetModifiedSources(Func> filter, [EnumeratorCancellation] CancellationToken token) { - // iterate over volumes - foreach (var volumeData in m_volumeDataDict) + foreach (var volumeData in m_volumeDataDict.Values.Where(x => !x.IsFullScan)) { // prepare cache for includes (value = true) and excludes (value = false, will be populated // on-demand) var cache = new Dictionary(); - foreach (var source in m_sources) + foreach (var source in m_snapshot.SourceEntries) { - if (m_token.IsCancellationRequested) - { - break; - } + if (token.IsCancellationRequested) + yield break; cache[source] = true; } @@ -244,17 +298,15 @@ namespace Duplicati.Library.Snapshots // FilterHandler.EnumerateFilesAndFolders() works top-down, and won't even enumerate child // folders. // The sources are needed to stop evaluating parent folders above the specified source folders - if (volumeData.Value.Folders != null) + await foreach (var folder in FilterExcludedFolders(volumeData.Folders, m_snapshot, filter, cache, token).ConfigureAwait(false)) { - foreach (var folder in FilterExcludedFolders(volumeData.Value.Folders, filter, cache).Where(m_snapshot.DirectoryExists)) - { - if (m_token.IsCancellationRequested) - { - break; - } + if (token.IsCancellationRequested) + yield break; - yield return folder; - } + if (!m_snapshot.DirectoryExists(folder.Path)) + continue; + + yield return folder; } // The simplified file list also needs to be checked against the exclusion filter, as it @@ -263,17 +315,15 @@ namespace Duplicati.Library.Snapshots // Note that the simplified file list may contain entries that have been deleted! They need to // be kept in the list (unless excluded by the filter) in order for the backup handler to record their // deletion. - if (volumeData.Value.Files != null) + await foreach (var files in FilterExcludedFiles(volumeData.Files, m_snapshot, filter, cache, token).ConfigureAwait(false)) { - foreach (var files in FilterExcludedFiles(volumeData.Value.Files, filter, cache).Where(m_snapshot.FileExists)) - { - if (m_token.IsCancellationRequested) - { - break; - } + if (token.IsCancellationRequested) + yield break; - yield return files; - } + if (!m_snapshot.FileExists(files.Path)) + continue; + + yield return files; } } } @@ -283,45 +333,53 @@ namespace Duplicati.Library.Snapshots /// of its parent folders, is excluded by the filter. /// /// Files to filter + /// Snapshot service /// Exclusion filter /// Cache of included and excluded files / folders - /// + /// Cancellation token /// Filtered files - private IEnumerable FilterExcludedFiles(IEnumerable files, - Utility.Utility.EnumerationFilterDelegate filter, IDictionary cache, Utility.Utility.ReportAccessError errorCallback = null) + private static async IAsyncEnumerable FilterExcludedFiles( + IEnumerable? files, + ISnapshotService snapshot, + Func> filter, + IDictionary cache, + [EnumeratorCancellation] CancellationToken token) { - var result = new List(); + if (files == null) + yield break; - foreach (var file in files) + foreach (var filePath in files) { - if (m_token.IsCancellationRequested) - { - break; - } - - var attr = m_snapshot.FileExists(file) ? m_snapshot.GetAttributes(file) : FileAttributes.Normal; + ISourceProviderEntry? file; try { - if (!filter(file, file, attr)) + file = snapshot.GetFilesystemEntry(filePath, false); + if (file == null) continue; - if (!IsFolderOrAncestorsExcluded(Utility.Utility.GetParent(file, true), filter, cache)) + if (!await filter(file)) + continue; + + var parentPath = Utility.Utility.GetParent(file.Path, true); + if (!string.IsNullOrWhiteSpace(parentPath)) { - result.Add(file); + var parent = snapshot.GetFilesystemEntry(parentPath, true); + if (parent == null) + continue; + + if (await IsFolderOrAncestorsExcluded(parent, snapshot, filter, cache, token)) + continue; } } - catch (System.Threading.ThreadAbortException) - { - throw; - } catch (Exception ex) { - errorCallback?.Invoke(file, file, ex); - filter(file, file, attr | Utility.Utility.ATTRIBUTE_ERROR); + Logging.Log.WriteWarningMessage(FILTER_LOGTAG, "FilterExcludedFilesError", ex, "Error while filtering file: {0}", ex.Message); + continue; } - } - return result; + if (file != null) + yield return file; + } } /// @@ -329,41 +387,40 @@ namespace Duplicati.Library.Snapshots /// of its ancestors, is excluded by the filter. /// /// Folder to filter + /// Snapshot service /// Exclusion filter - /// Cache of excluded folders (optional) - /// + /// Cache of excluded folders + /// Cancellation token /// Filtered folders - private IEnumerable FilterExcludedFolders(IEnumerable folders, - Utility.Utility.EnumerationFilterDelegate filter, IDictionary cache, Utility.Utility.ReportAccessError errorCallback = null) + private static async IAsyncEnumerable FilterExcludedFolders( + IEnumerable? folders, + ISnapshotService snapshot, + Func> filter, + IDictionary cache, + [EnumeratorCancellation] CancellationToken token) { - var result = new List(); + if (folders == null) + yield break; - foreach (var folder in folders) + foreach (var folderPath in folders) { - if (m_token.IsCancellationRequested) - { - break; - } - + ISourceProviderEntry? folder; try { - if (!IsFolderOrAncestorsExcluded(folder, filter, cache)) - { - result.Add(folder); - } - } - catch (System.Threading.ThreadAbortException) - { - throw; + folder = snapshot.GetFilesystemEntry(folderPath, true); + if (folder == null) + continue; + if (await IsFolderOrAncestorsExcluded(folder, snapshot, filter, cache, token)) + continue; } catch (Exception ex) { - errorCallback?.Invoke(folder, folder, ex); - filter(folder, folder, FileAttributes.Directory | Utility.Utility.ATTRIBUTE_ERROR); + Logging.Log.WriteWarningMessage(FILTER_LOGTAG, "FilterExcludedFoldersError", ex, "Error while filtering folder: {0}", ex.Message); + continue; } - } - return result; + yield return folder; + } } /// @@ -373,18 +430,23 @@ namespace Duplicati.Library.Snapshots /// Filter /// Cache of excluded folders (optional) /// True if excluded, false otherwise - private bool IsFolderOrAncestorsExcluded(string folder, Utility.Utility.EnumerationFilterDelegate filter, IDictionary cache) + private static async ValueTask IsFolderOrAncestorsExcluded( + ISourceProviderEntry inputFolder, + ISnapshotService snapshot, + Func> filter, + IDictionary cache, + CancellationToken token) { - List parents = null; + List? parents = null; + ISourceProviderEntry? folder = inputFolder; + while (folder != null) { - if (m_token.IsCancellationRequested) - { + if (token.IsCancellationRequested) break; - } // first check cache - if (cache.TryGetValue(folder, out var include)) + if (cache.TryGetValue(folder.Path, out var include)) { if (include) return false; @@ -392,20 +454,20 @@ namespace Duplicati.Library.Snapshots break; // hit! } + parents ??= []; // create on-demand + // remember folder for cache - if (parents == null) - { - parents = new List(); // create on-demand - } - parents.Add(folder); + parents.Add(folder.Path); - var attr = m_snapshot.DirectoryExists(folder) ? m_snapshot.GetAttributes(folder) : FileAttributes.Directory; - - if (!filter(folder, folder, attr)) + if (!await filter(folder)) break; // excluded - folder = Utility.Utility.GetParent(folder, true); + var parentPath = Utility.Utility.GetParent(folder.Path, true); + if (string.IsNullOrWhiteSpace(parentPath)) + break; + + folder = snapshot.GetFilesystemEntry(parentPath, true); } if (folder != null) @@ -422,7 +484,6 @@ namespace Duplicati.Library.Snapshots /// /// List of sources /// Dictionary of volumes, with list of sources as values - [SupportedOSPlatform("windows")] private static Dictionary> SortByVolume(IEnumerable sources) { var sourcesByVolume = new Dictionary>(); @@ -448,7 +509,6 @@ namespace Duplicati.Library.Snapshots /// /// /// - [SupportedOSPlatform("windows")] public bool IsPathEnumerated(string path) { // get NTFS volume root @@ -461,9 +521,12 @@ namespace Duplicati.Library.Snapshots if (volumeData.IsFullScan) return true; // do not append from previous set, already scanned - if (volumeData.Files.Contains(path)) + if (volumeData.Files != null && volumeData.Files.Contains(path)) return true; // do not append from previous set, already scanned + if (volumeData.Folders == null) + return false; + foreach (var folder in volumeData.Folders) { if (m_token.IsCancellationRequested) @@ -490,23 +553,23 @@ namespace Duplicati.Library.Snapshots /// /// Volume /// - public string Volume { get; set; } + public required string Volume { get; set; } /// /// Set of potentially modified files /// - public HashSet Files { get; internal set; } + public HashSet? Files { get; internal set; } /// /// Set of folders that are potentially modified, or whose children /// are potentially modified /// - public List Folders { get; internal set; } + public List? Folders { get; internal set; } /// /// Journal data to use for next backup /// - public USNJournalDataEntry JournalData { get; internal set; } + public USNJournalDataEntry? JournalData { get; internal set; } /// /// If true, a full scan for this volume was required @@ -516,6 +579,6 @@ namespace Duplicati.Library.Snapshots /// /// Optional exception message for volume /// - public Exception Exception { get; internal set; } + public Exception? Exception { get; internal set; } } } diff --git a/Duplicati/Library/Snapshots/Win32USN.cs b/Duplicati/Library/Snapshots/USN/Win32USN.cs similarity index 97% rename from Duplicati/Library/Snapshots/Win32USN.cs rename to Duplicati/Library/Snapshots/USN/Win32USN.cs index ed40e8c3f..3a9ebf7c2 100644 --- a/Duplicati/Library/Snapshots/Win32USN.cs +++ b/Duplicati/Library/Snapshots/USN/Win32USN.cs @@ -1,30 +1,30 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. using System; using System.Runtime.InteropServices; using System.Runtime.Versioning; using Microsoft.Win32.SafeHandles; -namespace Duplicati.Library.Snapshots +namespace Duplicati.Library.Snapshots.USN { //The signatures in this file are from http://pinvoke.net diff --git a/Duplicati/Library/Snapshots/Windows/AlphaVssBackup.cs b/Duplicati/Library/Snapshots/Windows/AlphaVssBackup.cs new file mode 100644 index 000000000..85d65f49b --- /dev/null +++ b/Duplicati/Library/Snapshots/Windows/AlphaVssBackup.cs @@ -0,0 +1,200 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Alphaleonis.Win32.Vss; +using Duplicati.Library.Common.IO; + +namespace Duplicati.Library.Snapshots.Windows; + +/// +/// Implementation of a snapshot, using AlphaVSS +/// +internal class AlphaVssBackup : ISnapshotProvider +{ + /// + /// The implementation that is being wrapped + /// + private IVssBackupComponents _components; + /// + /// Creates a new snapshot implementation + /// + /// The components to use + private AlphaVssBackup(IVssBackupComponents components) + { + _components = components; + } + + /// + public void EnableWriterClasses(Guid[] guids) + => _components.EnableWriterClasses(guids); + + /// + public void DisableWriterClasses(Guid[] guids) + => _components.DisableWriterClasses(guids); + + /// + public void GatherWriterMetadata() + => _components.GatherWriterMetadata(); + /// + public void FreeWriterMetadata() + => _components.FreeWriterMetadata(); + + /// + public void VerifyWriters(Guid[] guids) + { + foreach (var writerGUID in guids) + { + if (!_components.WriterMetadata.Any(o => o.WriterId.Equals(writerGUID))) + { + throw new Exception(string.Format("Writer with GUID {0} was not added to VSS writer set.", writerGUID.ToString())); + } + } + } + + /// + /// Stub for returning the snapshot info + /// + /// The path where the snapshot is located + private sealed record SnapshotInfo(string SnapshotDeviceObject) : ISnapshotInfo; + + /// + public ISnapshotInfo GetSnapshotProperties(Guid shadowId) + => new SnapshotInfo(_components.GetSnapshotProperties(shadowId).SnapshotDeviceObject); + + /// + public void StartSnapshotSet() + => _components.StartSnapshotSet(); + + /// + public void PrepareForBackup() + => _components.PrepareForBackup(); + + /// + public void DoSnapshotSet() + => _components.DoSnapshotSet(); + + /// + public bool IsVolumeSupported(string drive) + => _components.IsVolumeSupported(drive); + + /// + public Guid AddToSnapshotSet(string drive) + => _components.AddToSnapshotSet(drive); + + /// + public void BackupComplete() + => _components.BackupComplete(); + + /// + public void DeleteSnapshot(Guid shadowId, bool forceDelete) + => _components.DeleteSnapshot(shadowId, forceDelete); + + /// + public static ISnapshotProvider Create() + => new AlphaVssBackup(GetVssBackupComponents()); + + /// + /// Creates a new AlphaVssBackupComponents instance and adds the context + /// + /// The configured instance + private static IVssBackupComponents GetVssBackupComponents() + { + //Prepare the backup + IVssBackupComponents vssBackupComponents = CreateVssBackupComponents(); + vssBackupComponents.InitializeForBackup(null); + vssBackupComponents.SetContext(VssSnapshotContext.Backup); + vssBackupComponents.SetBackupState(false, true, VssBackupType.Full, false); + + return vssBackupComponents; + } + + /// + /// Loads the AlphaVSS implementation for the current OS + /// + /// The implementation + private static IVssBackupComponents CreateVssBackupComponents() + { + var vss = VssFactoryProvider.Default.GetVssFactory(); + if (vss == null) + throw new InvalidOperationException(); + + return vss.CreateVssBackupComponents(); + } + + /// + public IEnumerable ParseWriterMetaData(Guid[] writers) + { + // check if writers got enabled + foreach (var writerGUID in writers) + { + var writer = _components.WriterMetadata.First(o => o.WriterId.Equals(writerGUID)); + foreach (var component in writer.Components) + { + yield return new WriterMetaData + { + Guid = writerGUID, + Name = component.ComponentName, + LogicalPath = component.LogicalPath, + Paths = GetPathsFromComponent(component) + }; + } + } + } + + /// + /// Returns the paths from the writer metadata + /// + /// The component to get the paths from + /// The list of paths + private List GetPathsFromComponent(IVssWMComponent component) + { + var paths = new List(); + + foreach (var file in component.Files) + { + if (file.FileSpecification.Contains("*")) + { + if (Directory.Exists(Util.AppendDirSeparator(file.Path))) + paths.Add(Util.AppendDirSeparator(file.Path)); + } + else + { + var fileWithSpec = SystemIO.IO_OS.PathCombine(file.Path, file.FileSpecification); + if (File.Exists(fileWithSpec)) + paths.Add(fileWithSpec); + } + } + return paths; + + } + + /// + public void Dispose() + { + _components.Dispose(); + } +} \ No newline at end of file diff --git a/Duplicati/Library/Snapshots/HyperVUtility.cs b/Duplicati/Library/Snapshots/Windows/HyperVUtility.cs similarity index 89% rename from Duplicati/Library/Snapshots/HyperVUtility.cs rename to Duplicati/Library/Snapshots/Windows/HyperVUtility.cs index c45ecab9d..9e0f16524 100644 --- a/Duplicati/Library/Snapshots/HyperVUtility.cs +++ b/Duplicati/Library/Snapshots/Windows/HyperVUtility.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; @@ -24,10 +24,9 @@ using System.IO; using System.Linq; using System.Management; using System.Runtime.Versioning; -using Duplicati.Library.Common; using Duplicati.Library.Common.IO; -namespace Duplicati.Library.Snapshots +namespace Duplicati.Library.Snapshots.Windows { public class HyperVGuest : IEquatable { @@ -90,6 +89,11 @@ namespace Duplicati.Library.Snapshots /// private static readonly string LOGTAG = Logging.Log.LogTagFromType(); + /// + /// The System.IO abstraction for the Windows platform + /// + private static readonly ISystemIO IO_WIN = SystemIO.IO_OS; + private readonly ManagementScope _wmiScope; private readonly string _vmIdField; private readonly string _wmiHost = "localhost"; @@ -156,8 +160,9 @@ namespace Duplicati.Library.Snapshots /// Query Hyper-V for all Virtual Machines info /// /// Specify if returned data should contain VM paths + /// The provider to use for VSS /// List of Hyper-V Machines - public void QueryHyperVGuestsInfo(bool bIncludePaths = false) + public void QueryHyperVGuestsInfo(SnapshotProvider provider, bool bIncludePaths = false) { if (!IsHyperVInstalled) return; @@ -174,7 +179,7 @@ namespace Duplicati.Library.Snapshots if (bIncludePaths) { - foreach (var o in GetAllVMsPathsVSS()) + foreach (var o in GetAllVMsPathsVSS(provider)) { foreach (var mObject in moCollection) { @@ -214,9 +219,9 @@ namespace Duplicati.Library.Snapshots /// For all Hyper-V guests it enumerate all associated paths using VSS data /// /// A collection of VMs and paths - private static IEnumerable GetAllVMsPathsVSS() + private static IEnumerable GetAllVMsPathsVSS(SnapshotProvider provider) { - using (var vssBackupComponents = new VssBackupComponents()) + using (var vssBackupComponents = new SnapshotManager(provider)) { var writerGUIDS = new[] { HyperVWriterGuid }; @@ -251,7 +256,7 @@ namespace Duplicati.Library.Snapshots using (var mObject1 = new ManagementObjectSearcher(_wmiScope, new ObjectQuery(wmiQuery)).Get().Cast().First()) if (_wmiv2Namespace) { - path = SystemIO.IO_WIN.PathCombine((string)mObject1["ConfigurationDataRoot"], (string)mObject1["ConfigurationFile"]); + path = IO_WIN.PathCombine((string)mObject1["ConfigurationDataRoot"], (string)mObject1["ConfigurationFile"]); if (File.Exists(path)) result.Add(path); @@ -261,10 +266,10 @@ namespace Duplicati.Library.Snapshots { foreach (var snap in snaps) { - path = SystemIO.IO_WIN.PathCombine((string)snap["ConfigurationDataRoot"], (string)snap["ConfigurationFile"]); + path = IO_WIN.PathCombine((string)snap["ConfigurationDataRoot"], (string)snap["ConfigurationFile"]); if (File.Exists(path)) result.Add(path); - path = Util.AppendDirSeparator(SystemIO.IO_WIN.PathCombine((string)snap["ConfigurationDataRoot"], (string)snap["SuspendDataRoot"])); + path = Util.AppendDirSeparator(IO_WIN.PathCombine((string)snap["ConfigurationDataRoot"], (string)snap["SuspendDataRoot"])); if (Directory.Exists(path)) result.Add(path); } @@ -272,10 +277,10 @@ namespace Duplicati.Library.Snapshots } else { - path = SystemIO.IO_WIN.PathCombine((string)mObject1["ExternalDataRoot"], "Virtual Machines", vmID + ".xml"); + path = IO_WIN.PathCombine((string)mObject1["ExternalDataRoot"], "Virtual Machines", vmID + ".xml"); if (File.Exists(path)) result.Add(path); - path = Util.AppendDirSeparator(SystemIO.IO_WIN.PathCombine((string)mObject1["ExternalDataRoot"], "Virtual Machines", vmID)); + path = Util.AppendDirSeparator(IO_WIN.PathCombine((string)mObject1["ExternalDataRoot"], "Virtual Machines", vmID)); if (Directory.Exists(path)) result.Add(path); @@ -285,10 +290,10 @@ namespace Duplicati.Library.Snapshots foreach (var snapID in snapsIDs) { - path = SystemIO.IO_WIN.PathCombine((string)mObject1["SnapshotDataRoot"], "Snapshots", snapID.Replace("Microsoft:", "") + ".xml"); + path = IO_WIN.PathCombine((string)mObject1["SnapshotDataRoot"], "Snapshots", snapID.Replace("Microsoft:", "") + ".xml"); if (File.Exists(path)) result.Add(path); - path = Util.AppendDirSeparator(SystemIO.IO_WIN.PathCombine((string)mObject1["SnapshotDataRoot"], "Snapshots", snapID.Replace("Microsoft:", ""))); + path = Util.AppendDirSeparator(IO_WIN.PathCombine((string)mObject1["SnapshotDataRoot"], "Snapshots", snapID.Replace("Microsoft:", ""))); if (Directory.Exists(path)) result.Add(path); } diff --git a/Duplicati/Library/Snapshots/Windows/ISnapshotProvider.cs b/Duplicati/Library/Snapshots/Windows/ISnapshotProvider.cs new file mode 100644 index 000000000..db6a0965a --- /dev/null +++ b/Duplicati/Library/Snapshots/Windows/ISnapshotProvider.cs @@ -0,0 +1,118 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +#nullable enable + +using System; +using System.Collections.Generic; + +namespace Duplicati.Library.Snapshots.Windows; + +/// +/// Interface for the snapshot information +/// +internal interface ISnapshotInfo +{ + /// + /// The path where the snapshot is located + /// + string SnapshotDeviceObject { get; } +} + +/// +/// Interface for a snapshot provider +/// +internal interface ISnapshotProvider : IDisposable +{ + /// + /// Explicitly enables the selected writer GUIDs + /// + /// The GUIDs to enable + void EnableWriterClasses(Guid[] guids); + /// + /// Explicitly disable the selected writer GUIDs + /// + /// The GUIDs to disable + void DisableWriterClasses(Guid[] guids); + /// + /// Gather writer metadata prior to the snapshot + /// + void GatherWriterMetadata(); + /// + /// Release any allocated metadata + /// + void FreeWriterMetadata(); + /// + /// Verify that the writes are enabled on the snapshot + /// + /// The writer GUIDs to verify as enabled + void VerifyWriters(Guid[] guids); + + /// + /// Gets information of a snapshot + /// + /// The shadow copy id + /// The snapshot information + ISnapshotInfo GetSnapshotProperties(Guid shadowId); + + /// + /// Creates a snapshot + /// + void StartSnapshotSet(); + /// + /// Starts the snapshot as a backup snapshot + /// + void PrepareForBackup(); + /// + /// Finalizes the snapshot creation + /// + void DoSnapshotSet(); + /// + /// Checks if the volume is supported by the snapshot + /// + /// The drive to check + /// true if the volume is supported; false otherwise + bool IsVolumeSupported(string drive); + /// + /// Adds a drive to the snapshot set + /// + /// The drive to add + /// The shadow id + Guid AddToSnapshotSet(string drive); + + /// + /// Completes the snapshot after the backup has completed + /// + void BackupComplete(); + /// + /// Releases all resources held by the snapshot + /// + /// The snapshot shadow id + /// Flag to choose force deletion + void DeleteSnapshot(Guid shadowId, bool forceDelete); + + /// + /// Parses write metadata + /// + /// The writers to return information from + /// The writer metadata + IEnumerable ParseWriterMetaData(Guid[] writers); +} \ No newline at end of file diff --git a/Duplicati/Library/Snapshots/MSSQLUtility.cs b/Duplicati/Library/Snapshots/Windows/MSSQLUtility.cs similarity index 94% rename from Duplicati/Library/Snapshots/MSSQLUtility.cs rename to Duplicati/Library/Snapshots/Windows/MSSQLUtility.cs index 896c24686..7cbf8444e 100644 --- a/Duplicati/Library/Snapshots/MSSQLUtility.cs +++ b/Duplicati/Library/Snapshots/Windows/MSSQLUtility.cs @@ -1,31 +1,30 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. -using Duplicati.Library.Common; -using Duplicati.Library.Common.IO; +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + using System; using System.Collections.Generic; using System.Linq; using System.Runtime.Versioning; -namespace Duplicati.Library.Snapshots +namespace Duplicati.Library.Snapshots.Windows { public class MSSQLDB : IEquatable { @@ -152,14 +151,14 @@ namespace Duplicati.Library.Snapshots /// For all MS SQL databases it enumerate all associated paths using VSS data /// /// A collection of DBs and paths - public void QueryDBsInfo() + public void QueryDBsInfo(SnapshotProvider provider) { if (!IsMSSQLInstalled) return; m_DBs.Clear(); - using (var vssBackupComponents = new VssBackupComponents()) + using (var vssBackupComponents = new SnapshotManager(provider)) { var writerGUIDS = new [] { MSSQLWriterGuid }; try diff --git a/Duplicati/Library/Common/IO/VssBackupComponents.cs b/Duplicati/Library/Snapshots/Windows/SnapshotManager.cs similarity index 61% rename from Duplicati/Library/Common/IO/VssBackupComponents.cs rename to Duplicati/Library/Snapshots/Windows/SnapshotManager.cs index 8b87aa044..78d21fa91 100644 --- a/Duplicati/Library/Common/IO/VssBackupComponents.cs +++ b/Duplicati/Library/Snapshots/Windows/SnapshotManager.cs @@ -1,33 +1,36 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Runtime.Versioning; using Alphaleonis.Win32.Vss; +using Duplicati.Library.Common.IO; -namespace Duplicati.Library.Common.IO +namespace Duplicati.Library.Snapshots.Windows { + /// + /// Metadata from a writer instance + /// public class WriterMetaData { public string Name { get; set; } @@ -36,16 +39,21 @@ namespace Duplicati.Library.Common.IO public List Paths { get; set; } } + /// + /// The manager for the Windows snapshot + /// [SupportedOSPlatform("windows")] - public class VssBackupComponents : IDisposable + public class SnapshotManager : IDisposable { /// /// The tag used for logging messages /// - public static readonly string LOGTAG = Logging.Log.LogTagFromType(); + private static readonly string LOGTAG = Logging.Log.LogTagFromType(); - - private IVssBackupComponents _vssBackupComponents; + /// + /// The snapshot implementation + /// + private ISnapshotProvider _snapshotProvider; /// /// The list of snapshot ids for each volume, key is the path root, e.g. C:\. @@ -69,26 +77,39 @@ namespace Duplicati.Library.Common.IO /// private List _mappedDrives; - public VssBackupComponents() + /// + /// Creates a new snapshot manager + /// + /// The provider to use + public SnapshotManager(SnapshotProvider provider) { - _vssBackupComponents = VssBackupComponentsHelper.GetVssBackupComponents(); + _snapshotProvider = provider switch { + SnapshotProvider.AlphaVSS => AlphaVssBackup.Create(), + SnapshotProvider.Wmic => new WmicVssBackup(), + _ => throw new ArgumentException($"Invalid provider: {provider}") + }; } + /// + /// Setup writers on the snapshot + /// + /// The explicitly included writers + /// The explicitly excluded writers public void SetupWriters(Guid[] includedWriters, Guid[] excludedWriters) { if (includedWriters != null && includedWriters.Length > 0) - _vssBackupComponents.EnableWriterClasses(includedWriters); + _snapshotProvider.EnableWriterClasses(includedWriters); if (excludedWriters != null && excludedWriters.Length > 0) - _vssBackupComponents.DisableWriterClasses(excludedWriters); + _snapshotProvider.DisableWriterClasses(excludedWriters); try { - _vssBackupComponents.GatherWriterMetadata(); + _snapshotProvider.GatherWriterMetadata(); } finally { - _vssBackupComponents.FreeWriterMetadata(); + _snapshotProvider.FreeWriterMetadata(); } if (includedWriters == null) @@ -97,15 +118,12 @@ namespace Duplicati.Library.Common.IO } // check if writers got enabled - foreach (var writerGUID in includedWriters) - { - if (!_vssBackupComponents.WriterMetadata.Any(o => o.WriterId.Equals(writerGUID))) - { - throw new Exception(string.Format("Writer with GUID {0} was not added to VSS writer set.", writerGUID.ToString())); - } - } + _snapshotProvider.VerifyWriters(includedWriters); } + /// + /// Maps drives from snapshot paths + /// public void MapDrives() { _mappedDrives = new List(); @@ -124,6 +142,9 @@ namespace Duplicati.Library.Common.IO } } + /// + /// Makes a map of the snapshots for easy lookup + /// public void MapVolumesToSnapShots() { _volumeMap = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -133,41 +154,17 @@ namespace Duplicati.Library.Common.IO // Attempting to get attributes on this path will fail, so we need to append a double backslash // Strangely, the double backslash is not needed when acessing a subfolder, here a single backslash is enough, // but double backslash works for both cases due to path normalization. - _volumeMap.Add(kvp.Key, Util.AppendDirSeparator(_vssBackupComponents.GetSnapshotProperties(kvp.Value).SnapshotDeviceObject) + "\\"); + _volumeMap.Add(kvp.Key, Util.AppendDirSeparator(_snapshotProvider.GetSnapshotProperties(kvp.Value).SnapshotDeviceObject) + "\\"); } _volumeReverseMap = _volumeMap.ToDictionary(x => x.Value, x => x.Key); } - public Dictionary SnapshotDeviceAndVolumes - { - get - { - return _volumeReverseMap; - } - } - - private List GetPathsFromComponent(IVssWMComponent component) - { - var paths = new List(); - - foreach (var file in component.Files) - { - if (file.FileSpecification.Contains("*")) - { - if (Directory.Exists(Util.AppendDirSeparator(file.Path))) - paths.Add(Util.AppendDirSeparator(file.Path)); - } - else - { - var fileWithSpec = SystemIO.IO_WIN.PathCombine(file.Path, file.FileSpecification); - if (File.Exists(fileWithSpec)) - paths.Add(fileWithSpec); - } - } - return paths; - - } + /// + /// Returns the map of snapshot path and logical path + /// + public Dictionary SnapshotDeviceAndVolumes + => _volumeReverseMap; /// /// Enumerable to iterate over components of the given writers. @@ -176,38 +173,30 @@ namespace Duplicati.Library.Common.IO /// Anonymous object containing GUID, component name, logical path and paths of the component. /// Writers. public IEnumerable ParseWriterMetaData(Guid[] writers) - { - // check if writers got enabled - foreach (var writerGUID in writers) - { - var writer = _vssBackupComponents.WriterMetadata.First(o => o.WriterId.Equals(writerGUID)); - foreach (var component in writer.Components) - { - yield return new WriterMetaData - { - Guid = writerGUID, - Name = component.ComponentName, - LogicalPath = component.LogicalPath, - Paths = GetPathsFromComponent(component) - }; - } - } - } - + => _snapshotProvider.ParseWriterMetaData(writers); + /// + /// Prepares the snapshot and notifies all writers + /// + /// The soruces to include public void InitShadowVolumes(IEnumerable sources) { - _vssBackupComponents.StartSnapshotSet(); + _snapshotProvider.StartSnapshotSet(); - CheckSupportedVolumes(sources); + CheckAndAddSupportedVolumes(sources); //Make all writers aware that we are going to do the backup - _vssBackupComponents.PrepareForBackup(); + _snapshotProvider.PrepareForBackup(); //Create the shadow volumes - _vssBackupComponents.DoSnapshotSet(); + _snapshotProvider.DoSnapshotSet(); } + /// + /// Gets the snapshot path from the local path + /// + /// The local path + /// The snapshot path public string GetVolumeFromCache(string path) { if (!_volumeMap.TryGetValue(path, out var volumePath)) @@ -216,25 +205,30 @@ namespace Duplicati.Library.Common.IO return volumePath; } - public void CheckSupportedVolumes(IEnumerable sources) + /// + /// Checks if the volumes can be added and creates snapshots as needed + /// + /// The sources to create snapshots for + private void CheckAndAddSupportedVolumes(IEnumerable sources) { //Figure out which volumes are in the set _volumes = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var s in sources) { - var drive = SystemIO.IO_WIN.GetPathRoot(s); + var drive = SystemIO.IO_OS.GetPathRoot(s); if (!_volumes.ContainsKey(drive)) { - if (!_vssBackupComponents.IsVolumeSupported(drive)) + if (!_snapshotProvider.IsVolumeSupported(drive)) { throw new VssVolumeNotSupportedException(drive); } - _volumes.Add(drive, _vssBackupComponents.AddToSnapshotSet(drive)); + _volumes.Add(drive, _snapshotProvider.AddToSnapshotSet(drive)); } } } + /// public void Dispose() { try @@ -256,7 +250,7 @@ namespace Duplicati.Library.Common.IO try { - _vssBackupComponents?.BackupComplete(); + _snapshotProvider?.BackupComplete(); } catch (Exception ex) { @@ -265,13 +259,13 @@ namespace Duplicati.Library.Common.IO try { - if (_vssBackupComponents != null) + if (_snapshotProvider != null) { foreach (var g in _volumes.Values) { try { - _vssBackupComponents.DeleteSnapshot(g, false); + _snapshotProvider.DeleteSnapshot(g, false); } catch (Exception ex) { @@ -285,35 +279,11 @@ namespace Duplicati.Library.Common.IO Logging.Log.WriteVerboseMessage(LOGTAG, "VSSSnapShotDeleteCleanError", ex, "Failed during VSS esnapshot closing"); } - if (_vssBackupComponents != null) + if (_snapshotProvider != null) { - _vssBackupComponents.Dispose(); - _vssBackupComponents = null; + _snapshotProvider.Dispose(); + _snapshotProvider = null; } } } - - - public static class VssBackupComponentsHelper - { - public static IVssBackupComponents GetVssBackupComponents() - { - //Prepare the backup - IVssBackupComponents vssBackupComponents = CreateVssBackupComponents(); - vssBackupComponents.InitializeForBackup(null); - vssBackupComponents.SetContext(VssSnapshotContext.Backup); - vssBackupComponents.SetBackupState(false, true, VssBackupType.Full, false); - - return vssBackupComponents; - } - - public static IVssBackupComponents CreateVssBackupComponents() - { - var vss = VssFactoryProvider.Default.GetVssFactory(); - if (vss == null) - throw new InvalidOperationException(); - - return vss.CreateVssBackupComponents(); - } - } } diff --git a/Duplicati/Library/Snapshots/Windows/WmicShadowCopyManager.cs b/Duplicati/Library/Snapshots/Windows/WmicShadowCopyManager.cs new file mode 100644 index 000000000..ab54530b8 --- /dev/null +++ b/Duplicati/Library/Snapshots/Windows/WmicShadowCopyManager.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.Versioning; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +#nullable enable + +namespace Duplicati.Library.Snapshots.Windows; + +/// +/// A shadow copy manager using the wmic commandline tool +/// +[SupportedOSPlatform("windows")] +internal class WmicShadowCopyManager : IDisposable +{ + /// + /// The tag used for logging messages + /// + private static readonly string LOGTAG = Logging.Log.LogTagFromType(); + + /// + /// A single shadow copy + /// + /// The shadow ID as a string + /// The shadow ID as a GUID + /// The drive that the snapshot is for + /// The path that contains the snapshot + public class WmicShadowCopy(string shadowId, Guid parsedId, string originalDrive, string mappedPath) : IDisposable + { + /// + /// Gets the shadow ID + /// + public string ShadowID { get; } = shadowId; + /// + /// Gets the shadow ID + /// + public Guid ParsedId { get; } = parsedId; + /// + /// Gets the drive that was originally mapped + /// + public string OriginalDrive { get; } = originalDrive; + /// + /// Gets the path where the snapshot is found + /// + public string MappedPath { get; } = mappedPath; + + /// + /// Flag keeping track of the snapshot deletion state + /// + private bool _snapshotDeleted; + + /// + public void Dispose() + { + DeleteShadowCopy(); + } + + /// + /// Deletes the shadow copy + /// + private void DeleteShadowCopy() + { + if (_snapshotDeleted) + return; + if (!string.IsNullOrEmpty(ShadowID)) + { + _snapshotDeleted = true; + Logging.Log.WriteVerboseMessage(LOGTAG, "DeleteShadowCopy", $"Deleting Shadow Copy: {ShadowID}"); + DeleteShadow(ShadowID); + } + } + } + + /// + /// The list of the currently registered shadow copies + /// + private List _shadowCopies = new List(); + + /// + /// Gets the list of the currently registered shadow copies + /// + public IEnumerable ShadowCopies => _shadowCopies; + + /// + /// Creates a new snapshot for the given drive + /// + /// The drive to create the snapshot for + /// The created snapshot + public WmicShadowCopy Add(string drive) + { + var shadowId = CreateShadowCopy(drive); + if (string.IsNullOrEmpty(shadowId)) + throw new InvalidOperationException("Failed to create shadow copy"); + + var shadowPath = GetShadowPath(shadowId); + if (string.IsNullOrEmpty(shadowPath)) + { + DeleteShadow(shadowId); + throw new InvalidOperationException("Failed to get shadow copy path"); + } + + var snapshot = new WmicShadowCopy(shadowId, Guid.Parse(shadowId), drive, shadowPath); + _shadowCopies.Add(snapshot); + + return snapshot; + } + + /// + public void Dispose() + { + foreach (var shadow in ShadowCopies) + { + shadow.Dispose(); + } + _shadowCopies.Clear(); + } + + /// + /// Creates a shadow copy + /// + /// The drive to create the snapshot for + /// The shadow id + private static string? CreateShadowCopy(string drive) + { + string output = ExecuteCommand("wmic", $"shadowcopy call create Volume='{drive}'", 10000); + + // Extract ShadowID using regex + Match match = Regex.Match(output, @"ShadowID\s*=\s*""({[0-9A-F\-]+})"""); + if (match.Success) + { + return match.Groups[1].Value; + } + + Logging.Log.WriteErrorMessage(LOGTAG, "ShadowCopyFailed", null, "Failed to create shadow copy for {0}: {1}", drive, output); + return null; + } + + /// + /// Gets the path where the shadow copy is mounted + /// + /// The shadow copy id + /// The path where the copy is mounted + private static string? GetShadowPath(string shadowId) + { + string output = ExecuteCommand("wmic", "shadowcopy get ID, DeviceObject", 5000); + + // Extract DeviceObject corresponding to the ShadowID + string pattern = $@"(\\\\\?\\GLOBALROOT\\Device\\HarddiskVolumeShadowCopy\d+)\s+{shadowId}"; + Match match = Regex.Match(output, pattern); + + if (match.Success) + { + return match.Groups[1].Value; + } + + Logging.Log.WriteErrorMessage(LOGTAG, "ShadowCopyFailed", null, "Failed to get shadow copy path for {0}: {1}", shadowId, output); + return null; + } + + /// + /// Returns the drives that are vss enabled + /// + /// + public static HashSet GetVssCapableDrivesViaVssadmin() + { + var vssDrives = new HashSet(StringComparer.OrdinalIgnoreCase); + + try + { + var output = ExecuteCommand("vssadmin", "list volumes", 5000); + + // Regex to match drive letters in output + var matches = Regex.Matches(output, @"Volume\s+path:\s*([A-Z]:)\\?\s", RegexOptions.IgnoreCase); + foreach (Match match in matches) + vssDrives.Add(match.Groups[1].Value.Substring(0, 1)); + } + catch (Exception ex) + { + Logging.Log.WriteErrorMessage(LOGTAG, "ShadowCopyListFailed", ex, "Failed to list volumes"); + } + + return vssDrives; + } + + /// + /// Delete a shadow copy + /// + /// The shadow id + private static void DeleteShadow(string shadowId) + => ExecuteCommand("wmic", $"shadowcopy where ID=\"{shadowId}\" delete", 5000); + + /// + /// Executes a command + /// + /// The binary to execute + /// The arguments to use + /// The timeout + /// The output of the command + private static string ExecuteCommand(string fileName, string arguments, int timeoutMs) + { + try + { + using (var process = new Process()) + { + process.StartInfo.FileName = fileName; + process.StartInfo.Arguments = arguments; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.UseShellExecute = false; + process.StartInfo.CreateNoWindow = true; + + process.Start(); + + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + + if (!process.WaitForExit(timeoutMs)) + { + process.Kill(); + throw new TimeoutException($"Command '{fileName} {arguments}' timed out after {timeoutMs}ms."); + } + + var output = outputTask.Result; + var error = errorTask.Result; + + if (!string.IsNullOrWhiteSpace(error)) + Logging.Log.WriteWarningMessage(LOGTAG, "ShadowCopyFailed", null, "Failed to execute command: {0} {1}: {2}", fileName, arguments, error); + + return output; + } + } + catch (Exception ex) + { + Logging.Log.WriteErrorMessage(LOGTAG, "ShadowCopyFailed", ex, "Failed to execute command: {0} {1}", fileName, arguments); + return string.Empty; + } + } +} \ No newline at end of file diff --git a/Duplicati/Library/Snapshots/Windows/WmicVssBackup.cs b/Duplicati/Library/Snapshots/Windows/WmicVssBackup.cs new file mode 100644 index 000000000..bf7568ed0 --- /dev/null +++ b/Duplicati/Library/Snapshots/Windows/WmicVssBackup.cs @@ -0,0 +1,131 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Versioning; + +namespace Duplicati.Library.Snapshots.Windows; + +/// +/// Snapshot provider based on WMIC +/// +[SupportedOSPlatform("windows")] +internal class WmicVssBackup : ISnapshotProvider +{ + /// + /// The manager that contains the actual interface to WMIC + /// + private WmicShadowCopyManager _manager = new WmicShadowCopyManager(); + + /// + /// Gets the VSS enabled drives + /// + private Lazy> _vssEnabledDrives = new Lazy>(() => WmicShadowCopyManager.GetVssCapableDrivesViaVssadmin()); + + /// + public Guid AddToSnapshotSet(string drive) + => _manager.Add(drive).ParsedId; + + /// + public void BackupComplete() + { + } + + /// + public void DeleteSnapshot(Guid shadowId, bool forceDelete) + { + foreach (var x in _manager.ShadowCopies.Where(x => x.ParsedId == shadowId)) + x.Dispose(); + } + + /// + public void DisableWriterClasses(Guid[] guids) + { + } + + /// + public void Dispose() + { + _manager.Dispose(); + } + + /// + public void DoSnapshotSet() + { + } + + /// + public void EnableWriterClasses(Guid[] guids) + { + } + + /// + public void FreeWriterMetadata() + { + } + + /// + public void GatherWriterMetadata() + { + } + + /// + /// Stub for returning the snapshot info + /// + /// The path where the snapshot is located + private sealed record SnapshotInfo (string SnapshotDeviceObject) : ISnapshotInfo; + + /// + public ISnapshotInfo GetSnapshotProperties(Guid shadowId) + => new SnapshotInfo(_manager.ShadowCopies.First(x => x.ParsedId == shadowId).MappedPath); + + /// + public bool IsVolumeSupported(string drive) + { + if (string.IsNullOrWhiteSpace(drive)) + return false; + + return _vssEnabledDrives.Value.Contains(drive.Substring(0, 1)); + } + + /// + public IEnumerable ParseWriterMetaData(Guid[] writers) + => Enumerable.Empty(); + + /// + public void PrepareForBackup() + { + } + + /// + public void StartSnapshotSet() + { + } + + /// + public void VerifyWriters(Guid[] guids) + { + } +} \ No newline at end of file diff --git a/Duplicati/Library/Snapshots/WindowsSnapshot.cs b/Duplicati/Library/Snapshots/WindowsSnapshot.cs index 65df03a2c..bbe4154c3 100644 --- a/Duplicati/Library/Snapshots/WindowsSnapshot.cs +++ b/Duplicati/Library/Snapshots/WindowsSnapshot.cs @@ -1,31 +1,32 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. - using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.Versioning; using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using Duplicati.Library.Snapshots.Windows; namespace Duplicati.Library.Snapshots { @@ -44,23 +45,36 @@ namespace Duplicati.Library.Snapshots /// public static readonly string LOGTAG = Logging.Log.LogTagFromType(); + /// + /// The system IO for the current platform + /// + private static readonly ISystemIO IO_WIN = SystemIO.IO_OS; + /// /// The main reference to the backup controller /// - private readonly VssBackupComponents _vssBackupComponents; + private readonly SnapshotManager _snapshotManager; + + /// + /// The source folders included in the snapshot + /// + private readonly IReadOnlyList _sourceEntries; /// /// Constructs a new backup snapshot, using all the required disks /// /// Sources to determine which volumes to include in snapshot /// A set of commandline options - public WindowsSnapshot(IEnumerable sources, IDictionary options) + /// A flag indicating if symlinks should be followed + public WindowsSnapshot(IEnumerable sources, IDictionary options, bool followSymlinks) + : base(followSymlinks) { // For Windows, ensure we don't store paths with extended device path prefixes (i.e., @"\\?\" or @"\\?\UNC\") - sources = sources.Select(SystemIOWindows.RemoveExtendedDevicePathPrefix); + _sourceEntries = sources.Select(SystemIOWindows.RemoveExtendedDevicePathPrefix).ToList(); try { - _vssBackupComponents = new VssBackupComponents(); + var provider = Utility.Utility.ParseEnumOption(options.AsReadOnly(), "snapshot-provider", SnapshotProvider.AlphaVSS); + _snapshotManager = new SnapshotManager(provider); // Default to exclude the System State writer var excludedWriters = new Guid[] { new Guid("{e8132975-6f93-4464-a53e-1050253ae220}") }; @@ -72,16 +86,16 @@ namespace Duplicati.Library.Snapshots .Select(x => new Guid(x)) .ToArray(); } - _vssBackupComponents.SetupWriters(null, excludedWriters); + _snapshotManager.SetupWriters(null, excludedWriters); - _vssBackupComponents.InitShadowVolumes(sources); + _snapshotManager.InitShadowVolumes(_sourceEntries); - _vssBackupComponents.MapVolumesToSnapShots(); + _snapshotManager.MapVolumesToSnapShots(); //If we should map the drives, we do that now and update the volumeMap if (Utility.Utility.ParseBoolOption(options.AsReadOnly(), "vss-use-mapping")) { - _vssBackupComponents.MapDrives(); + _snapshotManager.MapDrives(); } } catch (Exception ex1) @@ -103,6 +117,26 @@ namespace Duplicati.Library.Snapshots } } + /// + /// Gets the source folders + /// + public override IEnumerable SourceEntries => _sourceEntries; + + /// + /// Enumerates the root source files and folders + /// + /// The source files and folders + public override IEnumerable EnumerateFilesystemEntries() + { + foreach (var folder in _sourceEntries) + { + if (folder.EndsWith(Path.DirectorySeparatorChar) || DirectoryExists(folder)) + yield return new SnapshotSourceFileEntry(this, Util.AppendDirSeparator(folder), true, true); + else + yield return new SnapshotSourceFileEntry(this, folder, false, true); + } + } + #region Private functions /// @@ -115,8 +149,8 @@ namespace Duplicati.Library.Snapshots { string[] tmp = null; var spath = ConvertToSnapshotPath(localFolderPath); - tmp = SystemIO.IO_WIN.GetDirectories(spath); - var root = Util.AppendDirSeparator(SystemIO.IO_WIN.GetPathRoot(localFolderPath)); + tmp = IO_WIN.GetDirectories(spath); + var root = Util.AppendDirSeparator(IO_WIN.GetPathRoot(localFolderPath)); var volumePath = Util.AppendDirSeparator(ConvertToSnapshotPath(root)); volumePath = SystemIOWindows.AddExtendedDevicePathPrefix(volumePath); @@ -140,10 +174,10 @@ namespace Duplicati.Library.Snapshots string[] files = null; var spath = ConvertToSnapshotPath(localFolderPath); - files = SystemIO.IO_WIN.GetFiles(spath); + files = IO_WIN.GetFiles(spath); // convert back to non-shadow, i.e., non-vss version - var root = Util.AppendDirSeparator(SystemIO.IO_WIN.GetPathRoot(localFolderPath)); + var root = Util.AppendDirSeparator(IO_WIN.GetPathRoot(localFolderPath)); var volumePath = Util.AppendDirSeparator(ConvertToSnapshotPath(root)); volumePath = SystemIOWindows.AddExtendedDevicePathPrefix(volumePath); @@ -158,18 +192,6 @@ namespace Duplicati.Library.Snapshots #region ISnapshotService Members - /// - /// Enumerates all files and folders in the snapshot, restricted to sources - /// - /// Sources to enumerate - /// The callback to invoke with each found path - /// The callback used to report errors - public override IEnumerable EnumerateFilesAndFolders(IEnumerable sources, Utility.Utility.EnumerationFilterDelegate callback, Utility.Utility.ReportAccessError errorCallback) - { - // For Windows, ensure we don't store paths with extended device path prefixes (i.e., @"\\?\" or @"\\?\UNC\") - return base.EnumerateFilesAndFolders(sources.Select(SystemIOWindows.RemoveExtendedDevicePathPrefix), callback, errorCallback); - } - /// /// Gets the last write time of a given file in UTC /// @@ -179,7 +201,7 @@ namespace Duplicati.Library.Snapshots { var spath = ConvertToSnapshotPath(localPath); - return SystemIO.IO_WIN.GetLastWriteTimeUtc(SystemIOWindows.AddExtendedDevicePathPrefix(spath)); + return IO_WIN.GetLastWriteTimeUtc(SystemIOWindows.AddExtendedDevicePathPrefix(spath)); } /// @@ -191,7 +213,7 @@ namespace Duplicati.Library.Snapshots { var spath = ConvertToSnapshotPath(localPath); - return SystemIO.IO_WIN.GetCreationTimeUtc(SystemIOWindows.AddExtendedDevicePathPrefix(spath)); + return IO_WIN.GetCreationTimeUtc(SystemIOWindows.AddExtendedDevicePathPrefix(spath)); } /// @@ -201,7 +223,7 @@ namespace Duplicati.Library.Snapshots /// An open filestream that can be read public override Stream OpenRead(string localPath) { - return SystemIO.IO_WIN.FileOpenRead(ConvertToSnapshotPath(localPath)); + return IO_WIN.FileOpenRead(ConvertToSnapshotPath(localPath)); } /// @@ -211,7 +233,7 @@ namespace Duplicati.Library.Snapshots /// The length of the file public override long GetFileSize(string localPath) { - return SystemIO.IO_WIN.FileLength(ConvertToSnapshotPath(localPath)); + return IO_WIN.FileLength(ConvertToSnapshotPath(localPath)); } /// @@ -221,7 +243,7 @@ namespace Duplicati.Library.Snapshots /// The file or folder to examine public override FileAttributes GetAttributes(string localPath) { - return SystemIO.IO_WIN.GetFileAttributes(ConvertToSnapshotPath(localPath)); + return IO_WIN.GetFileAttributes(ConvertToSnapshotPath(localPath)); } /// @@ -232,7 +254,7 @@ namespace Duplicati.Library.Snapshots public override string GetSymlinkTarget(string localPath) { var spath = ConvertToSnapshotPath(localPath); - return SystemIO.IO_WIN.GetSymlinkTarget(spath); + return IO_WIN.GetSymlinkTarget(spath); } /// @@ -241,23 +263,16 @@ namespace Duplicati.Library.Snapshots /// The metadata for the given file or folder /// The file or folder to examine /// A flag indicating if the target is a symlink - /// A flag indicating if a symlink should be followed - public override Dictionary GetMetadata(string localPath, bool isSymlink, bool followSymlink) + public override Dictionary GetMetadata(string localPath, bool isSymlink) { - return SystemIO.IO_WIN.GetMetadata(ConvertToSnapshotPath(localPath), isSymlink, followSymlink); + return SystemIO.IO_OS.GetMetadata(ConvertToSnapshotPath(localPath), isSymlink, FollowSymlinks); } /// - public override bool IsBlockDevice(string localPath) - { - return false; - } + public override bool IsBlockDevice(string localPath) => false; /// - public override string HardlinkTargetID(string localPath) - { - return null; - } + public override string HardlinkTargetID(string localPath) => null; /// public override string ConvertToLocalPath(string snapshotPath) @@ -265,10 +280,10 @@ namespace Duplicati.Library.Snapshots if (!Path.IsPathRooted(snapshotPath)) throw new InvalidOperationException(); - foreach (var kvp in _vssBackupComponents.SnapshotDeviceAndVolumes) + foreach (var kvp in _snapshotManager.SnapshotDeviceAndVolumes) { if (snapshotPath.StartsWith(kvp.Key, Utility.Utility.ClientFilenameStringComparison)) - return SystemIO.IO_WIN.PathCombine(kvp.Value, snapshotPath.Substring(kvp.Key.Length)); + return IO_WIN.PathCombine(kvp.Value, snapshotPath.Substring(kvp.Key.Length)); } throw new InvalidOperationException(); @@ -283,8 +298,8 @@ namespace Duplicati.Library.Snapshots if (!Path.IsPathRooted(localPath)) throw new InvalidOperationException(); - var root = SystemIO.IO_WIN.GetPathRoot(localPath); - var volumePath = _vssBackupComponents.GetVolumeFromCache(root); + var root = IO_WIN.GetPathRoot(localPath); + var volumePath = _snapshotManager.GetVolumeFromCache(root); // Note that using a simple Path.Combine() for the following code // can result in invalid snapshot paths; e.g., if localPath is @@ -303,18 +318,15 @@ namespace Duplicati.Library.Snapshots /// public override bool FileExists(string localFilePath) { - return SystemIO.IO_WIN.FileExists(ConvertToSnapshotPath(localFilePath)); + return IO_WIN.FileExists(ConvertToSnapshotPath(localFilePath)); } /// public override bool DirectoryExists(string localFolderPath) { - return SystemIO.IO_WIN.DirectoryExists(ConvertToSnapshotPath(localFolderPath)); + return IO_WIN.DirectoryExists(ConvertToSnapshotPath(localFolderPath)); } - /// - public override bool IsSnapshot => true; - #endregion /// @@ -322,7 +334,7 @@ namespace Duplicati.Library.Snapshots { if (disposing) { - _vssBackupComponents?.Dispose(); + _snapshotManager?.Dispose(); } base.Dispose(disposing); diff --git a/Duplicati/Library/SourceProvider/Builtin/BackendSourceFileEntry.cs b/Duplicati/Library/SourceProvider/Builtin/BackendSourceFileEntry.cs new file mode 100644 index 000000000..b9e134014 --- /dev/null +++ b/Duplicati/Library/SourceProvider/Builtin/BackendSourceFileEntry.cs @@ -0,0 +1,345 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.IO.Pipelines; +using System.Runtime.CompilerServices; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using Duplicati.Library.Utility; + +namespace Duplicati.Library.SourceProvider; + +/// +/// Creates a new backend source entry +/// +/// The parent backend +/// The path of the entry +/// True if the entry is a folder +/// True if the entry is a meta entry +/// The creation time of the entry +/// The last modification time of the entry +/// The size of the entry +public class BackendSourceFileEntry(BackendSourceProvider parent, string path, bool isFolder, bool isRootEntry, DateTime createdUtc, DateTime lastModificationUtc, long size) + : ISourceProviderEntry +{ + /// + /// The log tag for this instance + /// + private static string LOGTAG = Logging.Log.LogTagFromType(); + + /// + public bool IsFolder => isFolder; + + /// + public bool IsMetaEntry => false; + + /// + public bool IsRootEntry => isRootEntry; + + /// + public DateTime CreatedUtc => createdUtc; + + /// + public DateTime LastModificationUtc => lastModificationUtc; + + /// + public string Path => SystemIO.IO_OS.PathCombine(parent?.MountedPath, NormalizePathToLocalSystem(path)); + + /// + public long Size => size; + /// + public bool IsSymlink => false; + + /// + public string? SymlinkTarget => null; + + /// + public FileAttributes Attributes => IsFolder + ? FileAttributes.Directory + : FileAttributes.Normal; + + /// + public Dictionary MinorMetadata => new Dictionary(); + + /// + public bool IsBlockDevice => false; + + /// + public bool IsCharacterDevice => false; + + /// + public bool IsAlternateStream => false; + + /// + public string? HardlinkTargetId => null; + + /// + /// An async enumerator for the entries + /// + private IAsyncEnumerator? preparedEnumerator = null; + + /// + /// A flag to indicate if the prepared enumerator has any entries + /// + private bool preparedEnumeratorAny = false; + + /// + /// Prepares the enumerator for this entry + /// + /// The cancellation token + /// The prepared enumerator + public async Task PrepareEnumerator(CancellationToken cancellationToken) + { + if (!isFolder || !isRootEntry) + throw new InvalidOperationException("PrepareEnumerator can only be called on root folders"); + + if (preparedEnumerator != null) + throw new InvalidOperationException("PrepareEnumerator can only be called once"); + + var result = EnumerateInternal(cancellationToken).GetAsyncEnumerator(cancellationToken); + var prev = Interlocked.Exchange(ref preparedEnumerator, result); + if (prev != null) + throw new InvalidOperationException("PrepareEnumerator can only be called once"); + + // Advance to the first entry, so we are sure it does not throw exceptions + preparedEnumeratorAny = await result.MoveNextAsync().ConfigureAwait(false); + } + + /// + public async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (!isFolder) + throw new InvalidOperationException("Enumerate can only be called on folders"); + + if (isRootEntry) + { + // If we have a prepared enumerator, consume and use it + var enumerator = Interlocked.Exchange(ref preparedEnumerator, null); + if (enumerator != null) + { + if (preparedEnumeratorAny) + { + // It has already been advanced, so return the current value + yield return enumerator.Current; + + while (await enumerator.MoveNextAsync()) + yield return enumerator.Current; + } + + yield break; + } + } + + // Otherwise, enumerate the entries + await foreach (var entry in EnumerateInternal(cancellationToken).ConfigureAwait(false)) + yield return entry; + + } + + private IAsyncEnumerable EnumerateInternal(CancellationToken cancellationToken) + => parent.WrappedBackend.ListAsync(NormalizePathTo(path, '/'), cancellationToken) + // Remove the current and parent folder entries + .Where(x => !string.IsNullOrWhiteSpace(x.Name) && x.Name != "." && x.Name != "..") + // Remove sub-folder entries + .Where(x => !x.Name[0..^1].Contains('\\') && !x.Name[0..^1].Contains('/')) + // Convert to source file entries + .Select(x => + { + var localPath = SystemIO.IO_OS.PathCombine(path, NormalizePathToLocalSystem(x.Name)); + if (x.IsFolder) + localPath = Util.AppendDirSeparator(localPath); + + return new BackendSourceFileEntry( + parent, + localPath, + x.IsFolder, + false, + x.Created, + x.LastModification, + x.Size + ); + }); + + /// + public async Task FileExists(string path, CancellationToken cancellationToken) + { + if (!isFolder) + throw new InvalidOperationException("FileExists cannot be called on folders"); + + try + { + var entry = await parent.WrappedBackend.GetEntryAsync(NormalizePathTo(path, '/'), cancellationToken); + return entry != null && !entry.IsFolder; + } + catch (FileNotFoundException) + { + return false; + } + } + + /// + public Task OpenMetadataRead(CancellationToken cancellationToken) + => Task.FromResult(null); + + /// + /// Helper class for reporting th length of a stream + /// + /// The stream to wrap + /// The length to report + private class LengthReportingStream(Stream stream, long reportedLength) : OverrideableStream(stream) + { + /// + /// Track the position + /// + private long position = 0; + + /// + public override long Length => reportedLength; + + /// + public override long Position + { + get => position; + set => position = base.Position = value; + } + + /// + public override void Write(byte[] buffer, int offset, int count) + { + base.Write(buffer, offset, count); + position += count; + } + + /// + public override int Read(byte[] buffer, int offset, int count) + { + var read = base.Read(buffer, offset, count); + position += read; + return read; + } + + /// + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + var read = await base.ReadAsync(buffer, offset, count, cancellationToken); + position += read; + return read; + } + + /// + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + await base.WriteAsync(buffer, offset, count, cancellationToken); + position += count; + } + } + + /// + public async Task OpenRead(CancellationToken cancellationToken) + { + if (isFolder) + throw new InvalidOperationException("OpenRead can only be called on files"); + + if (parent.WrappedBackend is IStreamingBackend streamingBackend) + { + var pipe = new Pipe(); + + // Start writing data to the pipe asynchronously + _ = Task.Run(async () => + { + try + { + await streamingBackend.GetAsync(NormalizePathTo(path, '/'), pipe.Writer.AsStream(), cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + await pipe.Writer.CompleteAsync(ex).ConfigureAwait(false); + } + finally + { + await pipe.Writer.CompleteAsync().ConfigureAwait(false); + } + }) + .ContinueWith(t => + { + if (t.IsFaulted) + Logging.Log.WriteWarningMessage(LOGTAG, "ErrorPipingStream", t.Exception, "Error piping stream for {0}", this.Path); + }); + + // Return the readable stream so the caller can read data as it's produced + return new LengthReportingStream(pipe.Reader.AsStream(), size); + } + else + { + TempFile? tempFile = null; + TempFileStream? file = null; + try + { + tempFile = new TempFile(); + await parent.WrappedBackend.GetAsync(path, tempFile, cancellationToken); + file = TempFileStream.Create(tempFile); + return file; + } + catch + { + file?.Dispose(); + tempFile?.Dispose(); + throw; + } + } + } + + /// + /// Normalizes the path, turning backslashes into forward slashes, + /// or vice versa, depending on the platform + /// + /// The path to normalize + /// The normalized path + public static string NormalizePathToLocalSystem(string path) + => NormalizePathTo(path, System.IO.Path.DirectorySeparatorChar); + + /// + /// Normalizes the path, turning backslashes into forward slashes, + /// or vice versa, depending on the platform + /// + /// The path to normalize + /// The normalized path + public static string NormalizePathTo(string path, char separator) + { + if (string.IsNullOrEmpty(path)) + return path; + + return path + .Replace('/', separator) + .Replace('\\', separator); + } + + /// + /// Creates a new backend source entry from a file entry + /// + /// The parent backend + /// The file entry + /// The prefix to add to the path + /// The new backend source entry + public static BackendSourceFileEntry FromFileEntry(BackendSourceProvider parent, string prefix, IFileEntry entry) + => new BackendSourceFileEntry(parent, SystemIO.IO_OS.PathCombine(prefix, entry.Name), entry.IsFolder, false, entry.Created, entry.LastModification, entry.Size); +} + diff --git a/Duplicati/Library/SourceProvider/Builtin/BackendSourceProvider.cs b/Duplicati/Library/SourceProvider/Builtin/BackendSourceProvider.cs new file mode 100644 index 000000000..8e9c39853 --- /dev/null +++ b/Duplicati/Library/SourceProvider/Builtin/BackendSourceProvider.cs @@ -0,0 +1,99 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using Duplicati.Library.Interface; + +namespace Duplicati.Library.SourceProvider; + +/// +/// A source provider that wraps a backend +/// +/// The backend to wrap +/// The path to mount the backend +public class BackendSourceProvider(IFolderEnabledBackend backend, string mountedPath) : ISourceProvider, ISourceProviderModule +{ + /// + /// The wrapped backend + /// + public IFolderEnabledBackend WrappedBackend => backend; + + /// + /// The path where the provider is logically mounted + /// + public string MountedPath => mountedPath; + + /// + public string Key => backend.ProtocolKey; + + /// + public string DisplayName => backend.DisplayName; + + /// + public IList SupportedCommands => backend.SupportedCommands; + + /// + /// The prepared root entry, if any + /// + private BackendSourceFileEntry? preparedRoot; + + /// + /// Flags if the provider has been initialized + /// + private int isInitialized = 0; + + /// + /// Creates a root entry + /// + /// The root entry + private BackendSourceFileEntry CreateRoot() + => new BackendSourceFileEntry(this, "", true, true, new DateTime(0), new DateTime(0), 0); + + /// + public async Task Initialize(CancellationToken cancellationToken) + { + // Only allow a single intiiialization call + if (Interlocked.Exchange(ref isInitialized, 1) != 0) + return; + + // Prepare the root entry + var root = CreateRoot(); + await root.PrepareEnumerator(cancellationToken).ConfigureAwait(false); + preparedRoot = root; + } + + /// + public IAsyncEnumerable Enumerate(CancellationToken cancellationToken) + => new[] { Interlocked.Exchange(ref preparedRoot, null) ?? CreateRoot() }.ToAsyncEnumerable(); + + /// + public async Task GetEntry(string path, bool isFolder, CancellationToken cancellationToken) + { + var entry = await backend.GetEntryAsync(BackendSourceFileEntry.NormalizePathTo(path, '/'), cancellationToken).ConfigureAwait(false); + return entry == null ? null : BackendSourceFileEntry.FromFileEntry(this, path, entry); + } + + /// + public void Dispose() + { + WrappedBackend.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/Duplicati/Library/SourceProvider/Builtin/Combiner.cs b/Duplicati/Library/SourceProvider/Builtin/Combiner.cs new file mode 100644 index 000000000..5205c049e --- /dev/null +++ b/Duplicati/Library/SourceProvider/Builtin/Combiner.cs @@ -0,0 +1,102 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Runtime.CompilerServices; +using Duplicati.Library.Interface; + +namespace Duplicati.Library.SourceProvider; + +/// +/// Helper class that wraps multiple source providers into a single source provider +/// +public class Combiner(IEnumerable providers) : ISourceProvider +{ + /// + public string MountedPath => string.Empty; + + /// + /// The providers to combine + /// + private readonly List providers = providers.SelectMany(x => x is Combiner c ? c.providers.AsEnumerable() : [x]).ToList(); + + /// + public Task Initialize(CancellationToken cancellationToken) + => Task.WhenAll(providers.Select(x => x.Initialize(cancellationToken))); + + /// + public async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + foreach (var provider in providers) + { + await foreach (var entry in provider.Enumerate(cancellationToken).ConfigureAwait(false)) + yield return entry; + } + } + + /// + /// Provides access to the combined providers + /// + public IEnumerable Providers => providers; + + /// + /// Gets a filesystem entry for a given path + /// + /// The path to get + /// True if the path is a folder + /// The cancellation token + /// The filesystem entry + public async Task GetEntry(string path, bool isFolder, CancellationToken cancellationToken) + { + foreach (var provider in providers) + if (!string.IsNullOrWhiteSpace(MountedPath) && provider.MountedPath.StartsWith(path)) + { + var subpath = provider.MountedPath.Substring(path.Length); + return await provider.GetEntry(subpath, isFolder, cancellationToken).ConfigureAwait(false); + } + + foreach (var provider in providers.Where(x => string.IsNullOrWhiteSpace(x.MountedPath))) + { + var res = await provider.GetEntry(path, isFolder, cancellationToken).ConfigureAwait(false); + if (res != null) + return res; + } + + return null; + } + + /// + /// Creates a combined source provider from a list of providers + /// + /// The providers to combine + /// The combined provider + public static ISourceProvider Combine(IEnumerable providers) + { + var providerTotal = providers.SelectMany(x => x is Combiner c ? c.providers.AsEnumerable() : [x]).ToList(); + return providerTotal.Count == 1 ? providerTotal[0] : new Combiner(providers); + } + + /// + public void Dispose() + { + foreach (var provider in providers) + provider.Dispose(); + } +} diff --git a/Duplicati/Library/SourceProvider/Builtin/Duplicati.Library.SourceProvider.Builtin.csproj b/Duplicati/Library/SourceProvider/Builtin/Duplicati.Library.SourceProvider.Builtin.csproj new file mode 100644 index 000000000..e48c06ee4 --- /dev/null +++ b/Duplicati/Library/SourceProvider/Builtin/Duplicati.Library.SourceProvider.Builtin.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + Library + Duplicati.Library.SourceProvider + Copyright © 2024 Team Duplicati, MIT license + enable + enable + Duplicati.Library.SourceProvider + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/Duplicati/Library/SourceProvider/Builtin/LocalFileSource.cs b/Duplicati/Library/SourceProvider/Builtin/LocalFileSource.cs new file mode 100644 index 000000000..2568b4474 --- /dev/null +++ b/Duplicati/Library/SourceProvider/Builtin/LocalFileSource.cs @@ -0,0 +1,58 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using Duplicati.Library.Interface; +using Duplicati.Library.Snapshots; + +namespace Duplicati.Library.SourceProvider; + +/// +/// A source provider that wraps a file system through a snapshot service +/// +/// The snapshot service to use +public class LocalFileSource(ISnapshotService snapshotService) : ISourceProvider +{ + /// + public string MountedPath => string.Empty; + + /// + public Task Initialize(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public IAsyncEnumerable Enumerate(CancellationToken cancellationToken) + => snapshotService.EnumerateFilesystemEntries().ToAsyncEnumerable(); + + /// + /// The snapshot service being used + /// + public ISnapshotService SnapshotService => snapshotService; + + /// + public Task GetEntry(string path, bool isFolder, CancellationToken cancellationToken) + => Task.FromResult(snapshotService.GetFilesystemEntry(path, isFolder)); + + /// + public void Dispose() + { + snapshotService.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/Duplicati/Library/SourceProvider/Builtin/Strings.cs b/Duplicati/Library/SourceProvider/Builtin/Strings.cs new file mode 100644 index 000000000..64cc9e118 --- /dev/null +++ b/Duplicati/Library/SourceProvider/Builtin/Strings.cs @@ -0,0 +1,29 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. +using Duplicati.Library.Localization.Short; +namespace Duplicati.Library.SourceProvider.Strings +{ + internal static class File + { + public static string Description { get { return LC.L(@"This backend can read and write data to an file based backend. Allowed formats are ""file://hostname/folder"" and ""file://username:password@hostname/folder"". You may supply UNC paths (e.g.: ""file://\\server\folder"") or local paths (e.g.: (win) ""file://c:\folder"", (linux) ""file:///usr/pub/files""). Use the prefix ""vss://"" or ""lvm://"" to create snapshot based folders."); } } + public static string DisplayName { get { return LC.L(@"Local folder or drive"); } } + } +} diff --git a/Duplicati/Library/SourceProviders/Duplicati.Library.SourceProviders.csproj b/Duplicati/Library/SourceProviders/Duplicati.Library.SourceProviders.csproj new file mode 100644 index 000000000..64dae938d --- /dev/null +++ b/Duplicati/Library/SourceProviders/Duplicati.Library.SourceProviders.csproj @@ -0,0 +1,14 @@ + + + net8.0 + Central reference to all source providers + Duplicati.Library.Backend + Copyright © 2024 Team Duplicati, MIT license + enable + enable + + + + + + diff --git a/Duplicati/Library/SourceProviders/SourceProviderModules.cs b/Duplicati/Library/SourceProviders/SourceProviderModules.cs new file mode 100644 index 000000000..7bc0b612c --- /dev/null +++ b/Duplicati/Library/SourceProviders/SourceProviderModules.cs @@ -0,0 +1,45 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using Duplicati.Library.Interface; + +namespace Duplicati.Library.SourceProviders; + +/// +/// Shared list of statically linked source-provider modules +/// +public static class SourceProviderModules +{ + /// + /// The list of all built-in source-provider modules + /// + public static IReadOnlyList BuiltInSourceProviderModules => SupportedSourceProviders; + + /// + /// Calculate list once and cache it + /// + private static readonly IReadOnlyList SupportedSourceProviders = new ISourceProviderModule[] { + // ... No providers currently implemented + } + .Where(x => x != null) + .ToList(); +} + diff --git a/Duplicati/Library/UsageReporter/ReportSet.cs b/Duplicati/Library/UsageReporter/ReportSet.cs index d80b5549a..55ac80dcb 100644 --- a/Duplicati/Library/UsageReporter/ReportSet.cs +++ b/Duplicati/Library/UsageReporter/ReportSet.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -43,7 +43,7 @@ namespace Duplicati.Library.UsageReporter [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] private static void DoInitUID() { - Cached_UserID = Library.AutoUpdater.UpdaterManager.InstallID; + Cached_UserID = Library.AutoUpdater.DataFolderManager.InstallID; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] diff --git a/Duplicati/Library/UsageReporter/ReportSetUploader.cs b/Duplicati/Library/UsageReporter/ReportSetUploader.cs index 2b7d57d9b..9c396f760 100644 --- a/Duplicati/Library/UsageReporter/ReportSetUploader.cs +++ b/Duplicati/Library/UsageReporter/ReportSetUploader.cs @@ -1,31 +1,28 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using CoCoL; using System.Threading.Tasks; -using System.Collections.Generic; -using System.Linq; using System.IO; -using System.Net; using System.Net.Http; using Duplicati.Library.Utility; using System.Threading; @@ -62,7 +59,7 @@ namespace Duplicati.Library.UsageReporter public static Tuple> Run() { var channel = ChannelManager.CreateChannel( - buffersize: MAX_PENDING_UPLOADS, + buffersize: MAX_PENDING_UPLOADS, pendingWritersOverflowStrategy: QueueOverflowStrategy.LIFO ); @@ -85,13 +82,14 @@ namespace Duplicati.Library.UsageReporter if (fs.Length > 0) { using var request = new HttpRequestMessage(HttpMethod.Post, UPLOAD_URL); - + request.Content = new StreamContent(fs); using var timeoutToken = new CancellationTokenSource(); timeoutToken.CancelAfter(TimeSpan.FromSeconds(UPLOAD_OPERATION_TIMEOUT_SECONDS)); - - var response = await HttpClientHelper.DefaultClient.UploadStream(request, timeoutToken.Token); + + using var client = HttpClientHelper.CreateClient(); + using var response = await client.UploadStream(request, timeoutToken.Token); rc = (int)response.StatusCode; } else diff --git a/Duplicati/Library/Utility/CallContextSettings.cs b/Duplicati/Library/Utility/CallContextSettings.cs index c78bea518..66a1824cf 100644 --- a/Duplicati/Library/Utility/CallContextSettings.cs +++ b/Duplicati/Library/Utility/CallContextSettings.cs @@ -1,32 +1,32 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Net.Security; using System.Security.Cryptography.X509Certificates; - +using System.Threading; using Duplicati.Library.Interface; -using Duplicati.Library.Logging; namespace Duplicati.Library.Utility { @@ -81,12 +81,9 @@ namespace Duplicati.Library.Utility } set { - lock (CallContextSettings._lock) - { - var st = CallContextSettings.Settings; - st.Tempdir = value; - CallContextSettings.Settings = st; - } + var st = CallContextSettings.Settings; + st.Tempdir = value; + CallContextSettings.Settings = st; } } @@ -228,20 +225,58 @@ namespace Duplicati.Library.Utility public static class CallContextSettings { /// - /// The settings that are stored in the call context, which are not serializable + /// The call context settings for OAuth /// - private static readonly Dictionary _settings = new Dictionary(); + private static readonly AsyncLocal _settings = new AsyncLocal(); /// - /// The context setting types that are used and need their private setting key - /// to prevent overwriting each others settings. + /// The instances that are currently active /// - private static string contextSettingsType = null; + private static readonly ConcurrentDictionary _instances = new ConcurrentDictionary(); /// - /// Lock for protecting the dictionary + /// Disposable class for setting call context settings /// - public static readonly object _lock = new object(); + /// The type of the settings + private sealed class Disposer : IDisposable + { + /// + /// A flag indicating if the object has been disposed + /// + private bool m_disposed = false; + /// + /// The previous value + /// + private T m_prev; + /// + /// The current value + /// + private T m_current; + + /// + /// Initializes a new instance of the class. + /// + /// The current server URL + /// The previous server URL + public Disposer(T current, T prev) + { + _settings.Value = m_current = current; + this.m_prev = prev; + _instances.TryAdd(current, null); + } + + /// + public void Dispose() + { + if (m_disposed) + return; + GC.SuppressFinalize(this); + m_disposed = true; + if (_settings.Value.Equals(m_current)) + _settings.Value = m_prev; + _instances.TryRemove(m_current, out _); + } + } /// /// Gets or sets the values in the current call context @@ -249,40 +284,8 @@ namespace Duplicati.Library.Utility /// The settings. public static T Settings { - get - { - var key = ContextID; - if (string.IsNullOrWhiteSpace(key)) - return default(T); - - - if (!_settings.TryGetValue(key, out T res)) - lock (_lock) // if not present but context id is not null, wait - if (!_settings.TryGetValue(key, out res)) - return default(T); - - return res; - } - set - { - var key = ContextID; - if (!string.IsNullOrWhiteSpace(key)) - lock (_lock) - _settings[key] = value; - } - } - - /// - /// Starts the context wit a default value. - /// - /// The disposable handle for the context. - /// The initial value. - public static IDisposable StartContext(string settingsKey, T initial = default(T)) - { - contextSettingsType = settingsKey; - var res = new ContextGuard(); - Settings = initial; - return res; + get => _settings.Value; + set => _settings.Value = value; } /// @@ -291,72 +294,10 @@ namespace Duplicati.Library.Utility /// The disposable handle for the context. /// The initial value. public static IDisposable StartContext(T initial = default(T)) - { - return StartContext(typeof(T).ToString(), initial); - } + => new Disposer(initial, _settings.Value); - /// - /// Gets all instances currently registered - /// - internal static T[] GetAllInstances() - { - lock (_lock) - return _settings.Values.ToArray(); - } + public static IEnumerable GetAllInstances() + => _instances.Keys.ToList(); - /// - /// Help class for providing a way to release resources associated with a call context - /// - private class ContextGuard : IDisposable - { - /// - /// Randomly generated identifier - /// - private readonly string ID = Guid.NewGuid().ToString(); - - /// - /// Initializes a new instance of the - /// class, - /// and sets up the call context - /// - public ContextGuard() - { - CallContext.SetData(contextSettingsType, ID); - } - - /// - /// Releases all resource used by the - /// object. - /// - /// Call when you are finished using the - /// . The - /// method leaves the in an - /// unusable state. After calling , you must release all references to the - /// so the garbage collector - /// can reclaim the memory that the - /// was occupying. - public void Dispose() - { - lock (_lock) - { - // Release the resources, if any - _settings.Remove(ID); - } - - CallContext.SetData(contextSettingsType, null); - } - } - - /// - /// Gets the context ID for this call. - /// - /// The context identifier. - private static string ContextID - { - get - { - return contextSettingsType != null ? CallContext.GetData(contextSettingsType) as string : null; - } - } } } diff --git a/Duplicati/Library/Utility/Duplicati.Library.Utility.csproj b/Duplicati/Library/Utility/Duplicati.Library.Utility.csproj index 443571f36..d53703e2f 100644 --- a/Duplicati/Library/Utility/Duplicati.Library.Utility.csproj +++ b/Duplicati/Library/Utility/Duplicati.Library.Utility.csproj @@ -10,6 +10,7 @@ + diff --git a/Duplicati/Library/Utility/HelpOptionExtensions.cs b/Duplicati/Library/Utility/HelpOptionExtensions.cs new file mode 100644 index 000000000..209adfab3 --- /dev/null +++ b/Duplicati/Library/Utility/HelpOptionExtensions.cs @@ -0,0 +1,59 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.CommandLine.Builder; +using System.Linq; + +namespace Duplicati.Library.Utility; + +public static class HelpOptionExtensions +{ + /// + /// Aliases to support for help command + /// + private static readonly string[] AlternativeHelpStrings = ["help", "/help","--help", "usage", "/usage", "--usage", "/h", "-h"]; + + /// + /// Adds the following aliases: "help", "/help", "--usage", "/usage". + /// These aliases will work the same way as the default help options (-h, --help). + /// + /// The command line builder to extend. + /// The command line builder with additional help aliases. + public static CommandLineBuilder UseAdditionalHelpAliases(this CommandLineBuilder builder) + { + var rootHelpOption = builder.Command.Options.FirstOrDefault(o => o.GetType().Name == "HelpOption"); + if (rootHelpOption == null) return builder; + foreach (var alias in AlternativeHelpStrings) + if (!rootHelpOption.Aliases.Contains(alias)) + rootHelpOption.AddAlias(alias); + + return builder; + } + + /// + /// Returns true if any of the arguments are any of the help strings. + /// + /// Arguments + public static bool IsArgumentAnyHelpString(IEnumerable args) + => args != null && AlternativeHelpStrings.Any(x => args.Contains(x, StringComparer.OrdinalIgnoreCase)); +} \ No newline at end of file diff --git a/Duplicati/Library/Utility/HttpClientExtensions.cs b/Duplicati/Library/Utility/HttpClientExtensions.cs index b02633d63..1247818b1 100644 --- a/Duplicati/Library/Utility/HttpClientExtensions.cs +++ b/Duplicati/Library/Utility/HttpClientExtensions.cs @@ -25,6 +25,8 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +#nullable enable + namespace Duplicati.Library.Utility; /// @@ -43,20 +45,20 @@ public static class HttpClientExtensions /// Action for progress reporting /// Cancelation token /// - public static async Task DownloadFile(this HttpClient client, HttpRequestMessage request, string filename, Action progressReportingAction = null, CancellationToken cancellationToken = default) + public static async Task DownloadFile(this HttpClient client, HttpRequestMessage request, string filename, Action? progressReportingAction = null, CancellationToken cancellationToken = default) { - using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - using var fileStream = System.IO.File.Create(filename); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using var fileStream = System.IO.File.Create(filename); if (progressReportingAction != null) { using var ProgressReportingStream = new ProgressReportingStream(stream, progressReportingAction); - await ProgressReportingStream.CopyToAsync(fileStream,cancellationToken); + await ProgressReportingStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); } else { - await stream.CopyToAsync(fileStream, cancellationToken); + await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); } } @@ -69,19 +71,19 @@ public static class HttpClientExtensions /// Action for progress reporting /// Cancelation token /// - public static async Task DownloadFile(this HttpClient client, HttpRequestMessage request, Stream fileStream, Action progressReportingAction = null, CancellationToken cancellationToken = default) + public static async Task DownloadFile(this HttpClient client, HttpRequestMessage request, Stream fileStream, Action? progressReportingAction = null, CancellationToken cancellationToken = default) { - using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); if (progressReportingAction != null) { using var ProgressReportingStream = new ProgressReportingStream(stream, progressReportingAction); - await ProgressReportingStream.CopyToAsync(fileStream, cancellationToken); + await ProgressReportingStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); } else { - await stream.CopyToAsync(fileStream, cancellationToken); + await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); } } @@ -91,8 +93,6 @@ public static class HttpClientExtensions /// The Http client reference /// A prepared HttpRequestMessage (Presumably with a stream) /// Cancelation token - public static async Task UploadStream(this HttpClient client, HttpRequestMessage request,CancellationToken cancellationToken = default) - { - return await client.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken); - } + public static Task UploadStream(this HttpClient client, HttpRequestMessage request, CancellationToken cancellationToken = default) + => client.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken); } diff --git a/Duplicati/Library/Utility/HttpClientHelper.cs b/Duplicati/Library/Utility/HttpClientHelper.cs index c4c21d3e9..411eda3a3 100644 --- a/Duplicati/Library/Utility/HttpClientHelper.cs +++ b/Duplicati/Library/Utility/HttpClientHelper.cs @@ -19,10 +19,9 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. -using System; -using System.Collections.Generic; +#nullable enable + using System.Net.Http; -using Duplicati.Library.Interface; namespace Duplicati.Library.Utility; @@ -33,25 +32,11 @@ namespace Duplicati.Library.Utility; /// public static class HttpClientHelper { - - /// - /// Globally configured certificate validator to be used across the application. - /// Each backend can choose when creating the httpclient/requesthandler if it will - /// use this or the default (null) validator. - /// - public static SslCertificateValidator CertificateValidator { get; set; } - - /// - /// Central HttpClient singleton instance to be used across the application as - /// per the HttpClientFactory recommended pattern. - /// - public static HttpClient DefaultClient { get; private set; } - /// /// Reference to the HttpClientFactory instance so we can create specific clients /// when the singleton pattnern is not desirable. /// - private static IHttpClientFactory _factory { get; set; } + private static IHttpClientFactory? _factory { get; set; } /// /// Sets the factory reference. @@ -60,7 +45,6 @@ public static class HttpClientHelper public static void Configure(IHttpClientFactory factory) { _factory = factory; - DefaultClient = factory.CreateClient(); } /// @@ -82,7 +66,7 @@ public static class HttpClientHelper { return new HttpClient(handler); } - + /// /// Configures the httphandler with the certificate validator, if needed. /// diff --git a/Duplicati/Library/Utility/IFilter.cs b/Duplicati/Library/Utility/IFilter.cs index 994fff493..28e83ea7f 100644 --- a/Duplicati/Library/Utility/IFilter.cs +++ b/Duplicati/Library/Utility/IFilter.cs @@ -1,26 +1,24 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. -using System; - namespace Duplicati.Library.Utility { /// diff --git a/Duplicati/Library/Utility/OverrideableStream.cs b/Duplicati/Library/Utility/OverrideableStream.cs index dd65cbbd0..35e8a5273 100644 --- a/Duplicati/Library/Utility/OverrideableStream.cs +++ b/Duplicati/Library/Utility/OverrideableStream.cs @@ -1,28 +1,27 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. using System; -using System.Collections.Generic; -using System.Text; using System.IO; +using System.Threading.Tasks; namespace Duplicati.Library.Utility { @@ -33,79 +32,78 @@ namespace Duplicati.Library.Utility /// public class OverrideableStream : Stream { - protected System.IO.Stream m_basestream; + /// + /// The base stream that is wrapped + /// + protected Stream m_basestream; + /// + /// Creates a new instance + /// + /// The stream to wrap + /// public OverrideableStream(Stream basestream) { - if (basestream == null) - throw new ArgumentNullException(nameof(basestream)); - m_basestream = basestream; + m_basestream = basestream ?? throw new ArgumentNullException(nameof(basestream)); } - public override bool CanRead - { - get { return m_basestream.CanRead; } - } + /// + public override bool CanRead => m_basestream.CanRead; - public override bool CanSeek - { - get { return m_basestream.CanSeek; } - } + /// + public override bool CanSeek => m_basestream.CanSeek; - public override bool CanWrite - { - get { return m_basestream.CanWrite; } - } + /// + public override bool CanWrite => m_basestream.CanWrite; - public override void Flush() - { - m_basestream.Flush(); - } + /// + public override void Flush() => m_basestream.Flush(); - public override long Length - { - get { return m_basestream.Length; } - } + /// + public override long Length => m_basestream.Length; + /// public override long Position { - get - { - return m_basestream.Position; - } - set - { - m_basestream.Position = value; - } + get => m_basestream.Position; + set => m_basestream.Position = value; } + /// public override int Read(byte[] buffer, int offset, int count) - { - return m_basestream.Read(buffer, offset, count); - } + => m_basestream.Read(buffer, offset, count); + /// public override long Seek(long offset, System.IO.SeekOrigin origin) - { - return m_basestream.Seek(offset, origin); - } + => m_basestream.Seek(offset, origin); + /// public override void SetLength(long value) - { - m_basestream.SetLength(value); - } + => m_basestream.SetLength(value); + /// public override void Write(byte[] buffer, int offset, int count) - { - m_basestream.Write(buffer, offset, count); - } + => m_basestream.Write(buffer, offset, count); + /// + public override Task ReadAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) + => m_basestream.ReadAsync(buffer, offset, count, cancellationToken); + + /// + public override Task WriteAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) + => m_basestream.WriteAsync(buffer, offset, count, cancellationToken); + + /// + public override Task FlushAsync(System.Threading.CancellationToken cancellationToken) + => m_basestream.FlushAsync(cancellationToken); + + /// protected override void Dispose(bool disposing) { - if (m_basestream != null) - m_basestream.Dispose(); + m_basestream?.Dispose(); m_basestream = null; base.Dispose(disposing); } - + } } diff --git a/Duplicati/Library/Utility/RetryHelper.cs b/Duplicati/Library/Utility/RetryHelper.cs index 38554e15a..1bbcaae3e 100644 --- a/Duplicati/Library/Utility/RetryHelper.cs +++ b/Duplicati/Library/Utility/RetryHelper.cs @@ -19,6 +19,8 @@ // 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; @@ -27,7 +29,7 @@ namespace Duplicati.Library.Utility; public static class RetryHelper { - public static async Task Retry(Func> action, int maxRetries, TimeSpan delay, CancellationToken token) + public static async Task Retry(Func> action, Action? errorCallback, int maxRetries, TimeSpan delay, CancellationToken token) { var attempt = 0; @@ -37,21 +39,26 @@ public static class RetryHelper { return await action(); } - catch + catch (Exception ex) { + attempt++; + errorCallback?.Invoke(ex, attempt); + if (token.IsCancellationRequested) throw; if (attempt >= maxRetries) throw; - - attempt++; } await Task.Delay(delay, token); } } + public static async Task Retry(Func> action, int maxRetries, TimeSpan delay, CancellationToken token) + => await Retry(action, null, maxRetries, delay, token); + public static async Task Retry(Func action, Action? errorCallback, int maxRetries, TimeSpan delay, CancellationToken token) + => await Retry(async () => { await action(); return true; }, errorCallback, maxRetries, delay, token); public static async Task Retry(Func action, int maxRetries, TimeSpan delay, CancellationToken token) - => await Retry(async () => { await action(); return true; }, maxRetries, delay, token); + => await Retry(async () => { await action(); return true; }, null, maxRetries, delay, token); } diff --git a/Duplicati/Library/Utility/SHGetFolder.cs b/Duplicati/Library/Utility/SHGetFolder.cs new file mode 100644 index 000000000..fd1df513b --- /dev/null +++ b/Duplicati/Library/Utility/SHGetFolder.cs @@ -0,0 +1,104 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +#nullable enable + +namespace Duplicati.Library.Utility; +/// +/// Wrapper for SHGetKnownFolderPath to various system folders on Windows +/// +/// List of GUIDs: https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid +[SupportedOSPlatform("windows")] +public static class SHGetFolder +{ + /// + /// Gets the download folder path + /// + public static string? DownloadFolder => GetKnownFolderPath(new Guid("374DE290-123F-4565-9164-39C4925E467B")); + + /// + /// Gets the user profiles folder path + /// + public static string? UserProfilesFolder + { + get + { + try + { + // Prefered way, works on Windows Vista and later + var shPath = GetKnownFolderPath(new Guid("0762D272-C50A-4BB0-A382-697DCD729B80")); + if (!string.IsNullOrWhiteSpace(shPath) && Path.IsPathRooted(shPath) && Directory.Exists(shPath)) + return shPath; + } + catch + { + } + + try + { + // Fallback to "{systemDrive}\\Users" + var envPath = Environment.ExpandEnvironmentVariables("%SystemDrive%\\Users"); + if (!string.IsNullOrWhiteSpace(envPath) && Path.IsPathRooted(envPath) && Directory.Exists(envPath)) + return envPath; + } + catch + { + } + + try + { + // Fallback to "{systemDrive}\\Users", method 2 + var sysPath = Environment.GetFolderPath(Environment.SpecialFolder.System); + if (!string.IsNullOrWhiteSpace(sysPath) && Path.IsPathRooted(sysPath) && Directory.Exists(sysPath)) + { + var sysDir = Path.GetPathRoot(sysPath); + if (!string.IsNullOrWhiteSpace(sysDir)) + return Path.Combine(sysDir, "Users"); + } + } + catch + { + } + + // Most likely, this is the correct path + var path = "C:\\Users"; + if (Directory.Exists(path)) + return path; + + return null; + } + } + + /// + /// SHGetKnownFolderPath function to get the folder path + /// + /// The folder GUID + /// Get folder flags + /// The access token + /// The folder path + /// The HRESULT error code + [DllImport("Shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern int SHGetKnownFolderPath( + [MarshalAs(UnmanagedType.LPStruct)] Guid rfid, + uint dwFlags, + IntPtr hToken, + out IntPtr ppszPath); + + /// + /// Gets the folder path using SHGetKnownFolderPath + /// + /// The folder GUID + /// The folder path + private static string? GetKnownFolderPath(Guid folderGuid) + { + var result = SHGetKnownFolderPath(folderGuid, 0, IntPtr.Zero, out var outPath); + if (result != 0) + Marshal.ThrowExceptionForHR(result); // Throws an exception for the HRESULT error code + + var path = Marshal.PtrToStringUni(outPath); + Marshal.FreeCoTaskMem(outPath); // Free the memory allocated by SHGetKnownFolderPath + return path; + } +} diff --git a/Duplicati/Library/Utility/SslCertificateValidator.cs b/Duplicati/Library/Utility/SslCertificateValidator.cs index e21e36469..41e62bd6b 100644 --- a/Duplicati/Library/Utility/SslCertificateValidator.cs +++ b/Duplicati/Library/Utility/SslCertificateValidator.cs @@ -1,25 +1,26 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; +using System.Linq; using System.Net.Security; using System.Security.Cryptography.X509Certificates; @@ -28,52 +29,55 @@ namespace Duplicati.Library.Utility; public class SslCertificateValidator(bool acceptAll, string[] validHashes) { [Serializable] - public class InvalidCertificateException : Exception + public class InvalidCertificateException(string certificate, SslPolicyErrors error) + : Exception(Strings.SslCertificateValidator.VerifyCertificateException(error, certificate)) { - private readonly string m_certificate; - private readonly SslPolicyErrors m_errors = SslPolicyErrors.None; + private readonly string _mCertificate = certificate; + private readonly SslPolicyErrors _mErrors = error; - public string Certificate => m_certificate; - public SslPolicyErrors SslError => m_errors; - - public InvalidCertificateException(string certificate, SslPolicyErrors error) - : base(Strings.SslCertificateValidator.VerifyCertificateException(error, certificate)) - { - m_certificate = certificate; - m_errors = error; - } + public string Certificate => _mCertificate; + public SslPolicyErrors SslError => _mErrors; } public bool ValidateServerCertificate(object sender, X509Certificate cert, X509Chain chain, SslPolicyErrors sslPolicyErrors) { - - if (sslPolicyErrors == SslPolicyErrors.None) - return true; - if (acceptAll) return true; - - string certHash; - + try { - certHash = Utility.ByteArrayAsHexString(cert.GetCertHash()); - if (certHash != null && validHashes != null) + DateTime now = DateTime.Now; + + using var certificate = cert as X509Certificate2 ?? new X509Certificate2(cert); + + if (!IsDateValid(certificate, now)) + return false; + + if (validHashes != null) { - foreach (var hash in validHashes) - { - if (!string.IsNullOrEmpty(hash) && certHash.Equals(hash, StringComparison.OrdinalIgnoreCase)) - { + // Check main certificate hash + if (IsTrustedHash(Utility.ByteArrayAsHexString(certificate.GetCertHash()))) + return true; + + // Check chain certificate from root for the hash (this allows custom CA certificates hashes to be added) + if (chain?.ChainElements != null) + if (chain.ChainElements.Any(element => IsTrustedHash(Utility.ByteArrayAsHexString(element.Certificate.GetCertHash())) && IsDateValid(element.Certificate, now))) return true; - } - } } + + // If no hash is found, perform the standard validations + return sslPolicyErrors == SslPolicyErrors.None && certificate.Verify(); } catch (Exception ex) { throw new Exception(Strings.SslCertificateValidator.VerifyCertificateHashError(ex, sslPolicyErrors), ex); } - - return false; } + + private bool IsTrustedHash(string hash) => + !string.IsNullOrWhiteSpace(hash) && + validHashes.Any(validHash => !string.IsNullOrEmpty(validHash) && + hash.Equals(validHash, StringComparison.OrdinalIgnoreCase)); + private bool IsDateValid(X509Certificate2 cert, DateTime now) => now <= cert.NotAfter && now >= cert.NotBefore; + } \ No newline at end of file diff --git a/Duplicati/Library/Utility/TempFile.cs b/Duplicati/Library/Utility/TempFile.cs index 3a9db951c..aa9853ea0 100644 --- a/Duplicati/Library/Utility/TempFile.cs +++ b/Duplicati/Library/Utility/TempFile.cs @@ -1,23 +1,23 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; @@ -37,6 +37,7 @@ namespace Duplicati.Library.Utility private string m_path; private bool m_protect; + private bool m_disposed = false; #if DEBUG //In debug mode, we track the creation of temporary files, and encode the generating method into the name @@ -179,6 +180,8 @@ namespace Duplicati.Library.Utility protected void Dispose(bool disposing) { + m_disposed = true; + if (disposing) GC.SuppressFinalize(this); @@ -204,6 +207,9 @@ namespace Duplicati.Library.Utility ~TempFile() { + if (!m_disposed) + Logging.Log.WriteWarningMessage("TempFile", "Finalizer", null, "TempFile was not disposed, cleaning up"); + Dispose(false); } diff --git a/Duplicati/Library/Utility/TempFileStream.cs b/Duplicati/Library/Utility/TempFileStream.cs new file mode 100644 index 000000000..85b8efcd2 --- /dev/null +++ b/Duplicati/Library/Utility/TempFileStream.cs @@ -0,0 +1,97 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +#nullable enable + +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Duplicati.Library.Utility; + +/// +/// A stream that wraps a temporary file. +/// The temporary file is automatically deleted when the stream is disposed. +/// +public class TempFileStream : StreamUtil.WrappingAsyncStream +{ + /// + /// The temporary file instance. + /// + private readonly TempFile tempFile; + /// + /// The stream for the temporary file. + /// + private readonly Stream stream; + + /// + /// The path to the temporary file. + /// + public string Path => tempFile.Name; + + /// + /// Creates a new TempFileStream instance. + /// + /// The temporary file instance. + /// The stream for the temporary file. + private TempFileStream(TempFile tempFile, Stream stream) + : base(stream) + { + this.tempFile = tempFile; + this.stream = stream; + } + + /// + /// Creates a new TempFileStream instance. + /// + /// The temporary file instance. + /// The new TempFileStream instance. + public static TempFileStream Create(TempFile tempFile) + { + return new TempFileStream(tempFile, File.Open(tempFile.Name, FileMode.Open, FileAccess.ReadWrite, FileShare.None)); + } + + /// + /// Creates a new TempFileStream instance. + /// + /// The new TempFileStream instance. + public static TempFileStream Create() + { + var tempFile = new TempFile(); + return new TempFileStream(tempFile, File.Open(tempFile.Name, FileMode.Open, FileAccess.ReadWrite, FileShare.None)); + } + + /// + protected override Task ReadImplAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => stream.ReadAsync(buffer, offset, count, cancellationToken); + + /// + protected override Task WriteImplAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => stream.WriteAsync(buffer, offset, count, cancellationToken); + + /// + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + stream.Dispose(); + tempFile.Dispose(); + } +} diff --git a/Duplicati/Library/Utility/Timeparser.cs b/Duplicati/Library/Utility/Timeparser.cs index cc505b468..64f9a23cc 100644 --- a/Duplicati/Library/Utility/Timeparser.cs +++ b/Duplicati/Library/Utility/Timeparser.cs @@ -1,27 +1,25 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; -using System.Collections.Generic; -using System.Text; namespace Duplicati.Library.Utility { @@ -34,7 +32,7 @@ namespace Duplicati.Library.Utility { public static TimeSpan ParseTimeSpan(string datestring) { - DateTime dt = new DateTime(0, DateTimeKind.Local); + var dt = new DateTime(0, DateTimeKind.Local); return ParseTimeInterval(datestring, dt) - dt; } @@ -48,16 +46,15 @@ namespace Duplicati.Library.Utility if (offset.Kind == DateTimeKind.Unspecified) offset = new DateTime(offset.Ticks, DateTimeKind.Local); - int multiplier = negate ? -1 : 1; + var multiplier = negate ? -1 : 1; if (string.IsNullOrEmpty(datestring)) return offset; - if (String.Equals(datestring.Trim(), "now", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(datestring.Trim(), "now", StringComparison.OrdinalIgnoreCase)) return DateTime.Now; - long l; - if (long.TryParse(datestring, System.Globalization.NumberStyles.Integer, null, out l)) + if (long.TryParse(datestring, System.Globalization.NumberStyles.Integer, null, out var l)) return offset.AddSeconds(l * multiplier); if (DateTime.TryParse(datestring, System.Globalization.CultureInfo.CurrentCulture, System.Globalization.DateTimeStyles.AssumeLocal, out var t)) @@ -66,16 +63,15 @@ namespace Duplicati.Library.Utility if (Utility.TryDeserializeDateTime(datestring, out t)) return t; - char[] separators = new char[] { 's', 'm', 'h', 'D', 'W', 'M', 'Y' }; + var separators = new char[] { 's', 'm', 'h', 'D', 'W', 'M', 'Y' }; - int index = 0; - int previndex = 0; + int index; + var previndex = 0; while ((index = datestring.IndexOfAny(separators, previndex)) > 0) { - string partial = datestring.Substring(previndex, index - previndex).Trim(); - int factor; - if (!int.TryParse(partial, System.Globalization.NumberStyles.Integer, null, out factor)) + var partial = datestring.Substring(previndex, index - previndex).Trim(); + if (!int.TryParse(partial, System.Globalization.NumberStyles.Integer, null, out var factor)) throw new Exception(Strings.Timeparser.InvalidIntegerError(partial)); factor *= multiplier; @@ -134,11 +130,10 @@ namespace Duplicati.Library.Utility if (string.IsNullOrEmpty(datestring)) return offset; - if (String.Equals(datestring.Trim(), "now", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(datestring.Trim(), "now", StringComparison.OrdinalIgnoreCase)) return DateTime.UtcNow; - long l; - if (long.TryParse(datestring, System.Globalization.NumberStyles.Integer, null, out l)) + if (long.TryParse(datestring, System.Globalization.NumberStyles.Integer, null, out var l)) return keepTimeOfDay ? timeZoneInfo.DSTAwareAddSeconds(offset, l * multiplier) : timeZoneInfo.DSTAwareAddSeconds(DateTime.UtcNow, l * multiplier); @@ -149,16 +144,15 @@ namespace Duplicati.Library.Utility if (Utility.TryDeserializeDateTime(datestring, out t)) return t; - char[] separators = ['s', 'm', 'h', 'D', 'W', 'M', 'Y']; + var separators = new char[] { 's', 'm', 'h', 'D', 'W', 'M', 'Y' }; int index; - int previndex = 0; + var previndex = 0; while ((index = datestring.IndexOfAny(separators, previndex)) > 0) { - string partial = datestring.Substring(previndex, index - previndex).Trim(); - int factor; - if (!int.TryParse(partial, System.Globalization.NumberStyles.Integer, null, out factor)) + var partial = datestring.Substring(previndex, index - previndex).Trim(); + if (!int.TryParse(partial, System.Globalization.NumberStyles.Integer, null, out var factor)) throw new Exception(Strings.Timeparser.InvalidIntegerError(partial)); factor *= multiplier; diff --git a/Duplicati/Library/Utility/Uri.cs b/Duplicati/Library/Utility/Uri.cs index 344dcc78d..0e918132e 100644 --- a/Duplicati/Library/Utility/Uri.cs +++ b/Duplicati/Library/Utility/Uri.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -57,7 +57,8 @@ namespace Duplicati.Library.Utility /// public readonly string Host; /// - /// The server path, e.g. index.html + /// The server path, e.g. index.html. + /// Note that the path does NOT have a leading /. /// public readonly string Path; /// @@ -273,7 +274,7 @@ namespace Duplicati.Library.Utility if (!string.IsNullOrEmpty(path)) { - if (!string.IsNullOrEmpty(host) && !path.StartsWith("/", StringComparison.Ordinal)) + if (!string.IsNullOrEmpty(host)) s += "/"; s += path; } @@ -457,7 +458,7 @@ namespace Duplicati.Library.Utility /// The query to parse public static NameValueCollection ParseQueryString(string query) { - return Library.Utility.Uri.ParseQueryString(query, true); + return ParseQueryString(query, true); } /// @@ -537,7 +538,7 @@ namespace Duplicati.Library.Utility { var builder = new UriBuilder(url) { - Path = (new UrlPath(ExtractPath(url)).Append(path)).ToString(), + Path = new UrlPath(ExtractPath(url)).Append(path).ToString(), Query = query != null ? BuildUriQuery(query) : null }; return builder.Uri.AbsoluteUri; @@ -551,7 +552,7 @@ namespace Duplicati.Library.Utility /// URL. public static string ExtractPath(string url) { - return (new Uri(url)).Path; + return new Uri(url).Path; } /// diff --git a/Duplicati/Library/Utility/Utility.cs b/Duplicati/Library/Utility/Utility.cs index 8525e1d27..9264943d7 100644 --- a/Duplicati/Library/Utility/Utility.cs +++ b/Duplicati/Library/Utility/Utility.cs @@ -23,17 +23,20 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; -using System.Threading; -using System.Threading.Tasks; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; using Duplicati.Library.Common.IO; -using System.Globalization; -using System.Runtime.Versioning; -using System.Security.Cryptography.X509Certificates; -using System.Security.Cryptography; namespace Duplicati.Library.Utility { @@ -246,7 +249,7 @@ namespace Duplicati.Library.Utility /// A list of the full filenames and foldernames. Foldernames ends with the directoryseparator char public static IEnumerable EnumerateFileSystemEntries(string basepath) { - return EnumerateFileSystemEntries(basepath, (rootpath, path, attributes) => true, SystemIO.IO_OS.GetDirectories, Directory.GetFiles, null); + return EnumerateFileSystemEntries(basepath, SystemIO.IO_OS.GetDirectories, Directory.GetFiles, null); } /// @@ -276,13 +279,12 @@ namespace Duplicati.Library.Utility /// The search is recursive. /// /// The folder to look in - /// The function to call with the filenames /// A function to call that lists all folders in the supplied folder /// A function to call that lists all files in the supplied folder /// A function to call that obtains the attributes for an element, set to null to avoid reading attributes /// An optional function to call with error messages. /// A list of the full filenames - public static IEnumerable EnumerateFileSystemEntries(string rootpath, EnumerationFilterDelegate callback, FileSystemInteraction folderList, FileSystemInteraction fileList, ExtractFileAttributes? attributeReader, ReportAccessError? errorCallback = null) + public static IEnumerable EnumerateFileSystemEntries(string rootpath, FileSystemInteraction folderList, FileSystemInteraction fileList, ExtractFileAttributes? attributeReader, ReportAccessError? errorCallback = null) { var lst = new Stack(); @@ -292,8 +294,7 @@ namespace Duplicati.Library.Utility try { var attr = attributeReader?.Invoke(rootpath) ?? FileAttributes.Directory; - if (callback(rootpath, rootpath, attr)) - lst.Push(rootpath); + lst.Push(rootpath); } catch (System.Threading.ThreadAbortException) { @@ -302,7 +303,6 @@ namespace Duplicati.Library.Utility catch (Exception ex) { errorCallback?.Invoke(rootpath, rootpath, ex); - callback(rootpath, rootpath, FileAttributes.Directory | ATTRIBUTE_ERROR); } while (lst.Count > 0) @@ -319,8 +319,7 @@ namespace Duplicati.Library.Utility try { var attr = attributeReader?.Invoke(sf) ?? FileAttributes.Directory; - if (callback(rootpath, sf, attr)) - lst.Push(sf); + lst.Push(sf); } catch (System.Threading.ThreadAbortException) { @@ -329,7 +328,6 @@ namespace Duplicati.Library.Utility catch (Exception ex) { errorCallback?.Invoke(rootpath, sf, ex); - callback(rootpath, sf, FileAttributes.Directory | ATTRIBUTE_ERROR); } } } @@ -340,7 +338,6 @@ namespace Duplicati.Library.Utility catch (Exception ex) { errorCallback?.Invoke(rootpath, f, ex); - callback(rootpath, f, FileAttributes.Directory | ATTRIBUTE_ERROR); } string[]? files = null; @@ -357,54 +354,18 @@ namespace Duplicati.Library.Utility catch (Exception ex) { errorCallback?.Invoke(rootpath, f, ex); - callback(rootpath, f, FileAttributes.Directory | ATTRIBUTE_ERROR); } } if (files != null) { foreach (var s in files) - { - try - { - var attr = attributeReader?.Invoke(s) ?? FileAttributes.Normal; - if (!callback(rootpath, s, attr)) - continue; - } - catch (System.Threading.ThreadAbortException) - { - throw; - } - catch (Exception ex) - { - errorCallback?.Invoke(rootpath, s, ex); - callback(rootpath, s, ATTRIBUTE_ERROR); - continue; - } yield return s; - } } } } else { - try - { - var attr = attributeReader?.Invoke(rootpath) ?? FileAttributes.Normal; - if (!callback(rootpath, rootpath, attr)) - yield break; - } - catch (System.Threading.ThreadAbortException) - { - throw; - } - catch (Exception ex) - { - errorCallback?.Invoke(rootpath, rootpath, ex); - callback(rootpath, rootpath, ATTRIBUTE_ERROR); - yield break; - } - yield return rootpath; } } @@ -741,7 +702,7 @@ namespace Duplicati.Library.Utility /// /// The set of options to look for the setting in /// The value to look for in the settings - /// + /// The parsed value, or the default value (false). public static bool ParseBoolOption(IReadOnlyDictionary options, string value) { if (options.TryGetValue(value, out var opt)) @@ -750,6 +711,22 @@ namespace Duplicati.Library.Utility return false; } + /// + /// Parses an integer option from the option set, returning the default value if the option is not found or cannot be parsed + /// + /// The set of options to look for the setting in + /// The value to look for in the settings + /// The default value to return if there are no matches. + /// The parsed or default integer value. + public static TimeSpan ParseTimespanOption(IReadOnlyDictionary options, string value, string @default) + { + var opt = options.GetValueOrDefault(value); + if (string.IsNullOrWhiteSpace(opt)) + opt = @default; + + return Timeparser.ParseTimeSpan(opt); + } + /// /// Parses an enum found in the options dictionary /// @@ -758,7 +735,20 @@ namespace Duplicati.Library.Utility /// The value to look for in the settings /// The default value to return if there are no matches. /// The enum type parameter. - public static T ParseEnumOption(IReadOnlyDictionary options, string value, T @default) + public static T ParseEnumOption(IReadOnlyDictionary options, string value, T @default) where T : struct, Enum + { + return options.TryGetValue(value, out var opt) ? ParseEnum(opt, @default) : @default; + } + + /// + /// Parses a flags-type enum found in the options dictionary + /// + /// The parsed or default enum value. + /// The set of options to look for the setting in + /// The value to look for in the settings + /// The default value to return if there are no matches. + /// The enum type parameter. + public static T ParseFlagsOption(IReadOnlyDictionary options, string value, T @default) where T : struct, Enum { return options.TryGetValue(value, out var opt) ? ParseEnum(opt, @default) : @default; } @@ -770,7 +760,7 @@ namespace Duplicati.Library.Utility /// The string to parse. /// The default value to return if there are no matches. /// The enum type parameter. - public static T ParseEnum(string? value, T @default) + public static T ParseEnum(string? value, T @default) where T : struct, Enum { if (string.IsNullOrWhiteSpace(value)) return @default; @@ -781,6 +771,41 @@ namespace Duplicati.Library.Utility return @default; } + /// + /// Parses a string into a flags enum value. + /// + /// The enum type to parse. + /// The value to parse. + /// The default value to return if there are no matches. + /// + public static T ParseFlags(string? value, T @default) where T : struct, Enum + { + if (string.IsNullOrWhiteSpace(value)) + return @default; + + var flags = 0; + foreach (var s in value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) + { + var trimmed = s.Trim(); + if (Enum.TryParse(trimmed, true, out T flag)) + flags = flags | (int)(object)flag; + } + + return (T)(object)flags; + } + + /// + /// Parses an option with int value, returning the default value if the option is not found or cannot be parsed + /// + /// The set of options to look for the setting in + /// The value to look for in the settings + /// default value + /// + public static int ParseIntOption(IReadOnlyDictionary options, string value, int @default) + { + return options.TryGetValue(value, out var opt) && int.TryParse(opt ?? string.Empty, out var result) ? result : @default; + } + /// /// Converts a sequence of bytes to a hex string /// @@ -987,7 +1012,7 @@ namespace Duplicati.Library.Utility /// The collection to remove duplicate items from. /// The duplicate items in . /// The unique items from . - public static ISet GetUniqueItems(IEnumerable collection, out ISet duplicateItems) + public static IList GetUniqueItems(IEnumerable collection, out ISet duplicateItems) { return GetUniqueItems(collection, EqualityComparer.Default, out duplicateItems); } @@ -1000,18 +1025,21 @@ namespace Duplicati.Library.Utility /// The implementation to use when comparing values in the collection. /// The duplicate items in . /// The unique items from . - public static ISet GetUniqueItems(IEnumerable collection, IEqualityComparer comparer, out ISet duplicateItems) + public static IList GetUniqueItems(IEnumerable collection, IEqualityComparer comparer, out ISet duplicateItems) { var uniqueItems = new HashSet(comparer); + var results = new List(); duplicateItems = new HashSet(comparer); foreach (var item in collection) { - if (!uniqueItems.Add(item)) + if (uniqueItems.Add(item)) + results.Add(item); + else duplicateItems.Add(item); } - return uniqueItems; + return results; } // @@ -1535,6 +1563,24 @@ namespace Duplicati.Library.Utility return collection; } + /// + /// Probes the system for the presence of a loopback address on IPv4 + /// + public static bool HasIPv4Loopback => + NetworkInterface.GetAllNetworkInterfaces() + .Where(ni => ni.OperationalStatus == OperationalStatus.Up) + .SelectMany(ni => ni.GetIPProperties().UnicastAddresses) + .Any(addr => addr.Address.AddressFamily == AddressFamily.InterNetwork + && addr.Address.Equals(IPAddress.Loopback)); + + /// + /// On systems that have IPV4 and IPV6, the method will return the default loopback ( 127, 0, 0, 1) + /// On systems with IPV6 only, the method will return the IPV6 loopback (::1) + /// + /// + public static string IpVersionCompatibleLoopback => + HasIPv4Loopback ? IPAddress.Loopback.ToString() : $"[{IPAddress.IPv6Loopback.ToString()}]"; + /// /// Flattens an exception and its inner exceptions /// @@ -1555,5 +1601,71 @@ namespace Duplicati.Library.Utility foreach (var iex in FlattenException(ex.InnerException)) yield return iex; } + + /// + /// Guesses the URL scheme and returns it + /// + /// The URL to guess the scheme for + /// The guessed scheme, or null if no scheme was found + public static string? GuessScheme(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return null; + + var idx = url.IndexOf("://"); + if (idx < 0 && idx < 15 && idx + "://".Length < url.Length) + return null; + + return url.Substring(0, idx); + } + + /// + /// Returns a url that is safe to display, by removing any credentials + /// + /// The url to sanitize + /// The sanitized url + public static string GetUrlWithoutCredentials(string url) + { + // Assumed safe part of the url to show + const int maxShown = 25; + if (string.IsNullOrWhiteSpace(url)) + return url; + + // Use a reportable url without credentials + var sepIndex = Math.Max(0, url.IndexOf('|')) + 1; + var length = url.Length - sepIndex; + var shown = Math.Min(length, maxShown); + var hidden = length - maxShown; + var sanitizedUrl = $"{url[sepIndex..(sepIndex + shown)]}{new string('*', hidden)}"; + + // If we can parse it, this result is better + try + { + var uri = new Uri(url[sepIndex..]); + sanitizedUrl = new Uri($"{uri.Scheme}://{uri.Host}").SetPath(uri.Path).ToString(); + } + catch + { + } + + return sanitizedUrl; + } + + /// + /// Formats the string using the invariant culture + /// + /// The format string + /// The arguments to format + /// The formatted string + public static string FormatInvariant(this string format, params object?[] args) + => string.Format(CultureInfo.InvariantCulture, format, args); + + /// + /// Formats the string using the invariant culture + /// + /// The formattable string + /// The formatted string + public static string FormatInvariant(this FormattableString formattable) + => formattable.ToString(CultureInfo.InvariantCulture); } } diff --git a/Duplicati/License/VersionNumbers.cs b/Duplicati/License/VersionNumbers.cs index 0a458ea02..98a4bb71a 100644 --- a/Duplicati/License/VersionNumbers.cs +++ b/Duplicati/License/VersionNumbers.cs @@ -1,35 +1,43 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -using System; -using System.Collections.Generic; -using System.Text; +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. namespace Duplicati.License { + /// + /// The version numbers for the application, read from the assembly + /// public static class VersionNumbers { + /// + /// The Version Tag read from the embedded resource VersionTag.txt + /// public static readonly string TAG; + /// + /// The version number of the assembly + /// public static readonly string VERSION_NAME; - + + /// + /// Static constructor to read the version numbers from the assembly + /// static VersionNumbers() { string tag = ""; @@ -41,13 +49,13 @@ namespace Duplicati.License catch { } - + if (string.IsNullOrWhiteSpace(tag)) tag = ""; - + TAG = tag.Trim(); - var v = VersionNumber; + var v = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version.ToString(); if (!string.IsNullOrWhiteSpace(TAG)) v = " - " + TAG; #if DEBUG @@ -56,21 +64,5 @@ namespace Duplicati.License VERSION_NAME = v; } - - public static string Version - { - get - { - return VERSION_NAME; - } - } - - private static string VersionNumber - { - get - { - return System.Reflection.Assembly.GetExecutingAssembly().GetName().Version.ToString(); - } - } } } diff --git a/Duplicati/Server/Duplicati.Server.Serialization/Enums.cs b/Duplicati/Server/Duplicati.Server.Serialization/Enums.cs index 31c615634..714e450d3 100755 --- a/Duplicati/Server/Duplicati.Server.Serialization/Enums.cs +++ b/Duplicati/Server/Duplicati.Server.Serialization/Enums.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -53,7 +53,8 @@ namespace Duplicati.Server.Serialization ReadyError, Paused, Active, - ActivePaused + ActivePaused, + Disconnected } diff --git a/Duplicati/Server/Program.cs b/Duplicati/Server/Program.cs index 7b2fb9c0a..5af27775d 100644 --- a/Duplicati/Server/Program.cs +++ b/Duplicati/Server/Program.cs @@ -21,7 +21,6 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -44,17 +43,18 @@ namespace Duplicati.Server { public class Program { - - private static readonly string[] AlternativeHelpStrings = ["help", "/help", "usage", "/usage", "--help"]; - private static readonly string[] ParameterFileOptionStrings = ["parameters-file", "parameterfile"]; - private const string PING_PONG_KEEPALIVE_OPTION = "ping-pong-keepalive"; private const string WINDOWS_EVENTLOG_OPTION = "windows-eventlog"; private const string WINDOWS_EVENTLOG_LEVEL_OPTION = "windows-eventlog-level"; private const string DISABLE_DB_ENCRYPTION_OPTION = "disable-db-encryption"; private const string REQUIRE_DB_ENCRYPTION_KEY_OPTION = "require-db-encryption-key"; private const string SETTINGS_ENCRYPTION_KEY_OPTION = "settings-encryption-key"; + private const string DISABLE_UPDATE_CHECK_OPTION = "disable-update-check"; + private const string LOG_FILE_OPTION = "log-file"; + private const string LOG_LEVEL_OPTION = "log-level"; + private const string LOG_CONSOLE_OPTION = "log-console"; + #if DEBUG private const bool DEBUG_MODE = true; @@ -71,20 +71,10 @@ namespace Duplicati.Server /// public static readonly string StartupPath = Duplicati.Library.AutoUpdater.UpdaterManager.INSTALLATIONDIR; - /// - /// Name of the database file - /// - public const string SERVER_DATABASE_FILENAME = "Duplicati-server.sqlite"; - /// /// The environment variable prefix /// - private static readonly string ENV_NAME_PREFIX = Duplicati.Library.AutoUpdater.AutoUpdateSettings.AppName.ToUpper(CultureInfo.InvariantCulture); - - /// - /// The name of the environment variable that holds the path to the data folder used by Duplicati - /// - private static readonly string DATAFOLDER_ENV_NAME = ENV_NAME_PREFIX + "_HOME"; + private static readonly string ENV_NAME_PREFIX = AutoUpdateSettings.AppName.ToUpperInvariant(); /// /// Gets the folder where Duplicati data is stored @@ -227,7 +217,7 @@ namespace Duplicati.Server var commandlineOptions = optionsWithFilter.Item1; var filter = optionsWithFilter.Item2; - if (_args.Select(s => s.ToLower()).Intersect(AlternativeHelpStrings.Select(x => x.ToLower())).Any()) + if (HelpOptionExtensions.IsArgumentAnyHelpString(args)) { return ShowHelp(writeToConsoleOnException); } @@ -278,9 +268,9 @@ namespace Duplicati.Server DataConnection.LogError(null, "Error in updater", obj); }; - DuplicatiWebserver = StartWebServer(commandlineOptions, DataConnection).ConfigureAwait(false).GetAwaiter().GetResult(); + DuplicatiWebserver = StartWebServer(commandlineOptions, DataConnection).Await(); - UpdatePoller.Init(); + UpdatePoller.Init(Library.Utility.Utility.ParseBoolOption(commandlineOptions, DISABLE_UPDATE_CHECK_OPTION)); SetPurgeTempFilesTimer(commandlineOptions); @@ -298,8 +288,8 @@ namespace Duplicati.Server DataConnection.SetPreloadSettingsIfChanged(preloadDbSettings); EmitWarningsForConfigurationIssues(commandlineOptions); - Library.Logging.Log.WriteInformationMessage(LOGTAG, "ServerStarted", Strings.Program.ServerStarted(DuplicatiWebserver.Port)); - logMessageToConsole(Strings.Program.ServerStarted(DuplicatiWebserver.Port)); + Library.Logging.Log.WriteInformationMessage(LOGTAG, "ServerStarted", Strings.Program.ServerStarted(DuplicatiWebserver.Interface, DuplicatiWebserver.Port)); + logMessageToConsole(Strings.Program.ServerStarted(DuplicatiWebserver.Interface, DuplicatiWebserver.Port)); if (FIXMEGlobal.Origin == "Server" && DataConnection.ApplicationSettings.AutogeneratedPassphrase) { @@ -324,12 +314,31 @@ namespace Duplicati.Server ApplicationExitEvent.Set(); }); + var stopCounter = 0; + Console.CancelKeyPress += (sender, e) => + { + if (Interlocked.Increment(ref stopCounter) <= 1) + { + Log.WriteInformationMessage(LOGTAG, "CancelKeyPressed", "Cancel key pressed, stopping server"); + Task.Run(() => DuplicatiWebserver?.Stop()); + } + else + { + Log.WriteWarningMessage(LOGTAG, "CancelKeyPressed", null, "Cancel key pressed twice, terminating now"); + if (OperatingSystem.IsWindows()) + Environment.FailFast("Cancel key pressed twice, terminating now"); + else + Environment.Exit(0); + } + }; + ServerStartedEvent.Set(); ApplicationExitEvent.WaitOne(); } catch (SingleInstance.MultipleInstanceException mex) { crashed = true; + Log.WriteErrorMessage(LOGTAG, "MultipleInstanceError", mex, Strings.Program.ServerCrashed(mex.Message)); System.Diagnostics.Trace.WriteLine(Strings.Program.SeriousError(mex.ToString())); if (!writeToConsoleOnException) throw; @@ -339,6 +348,7 @@ namespace Duplicati.Server catch (Exception ex) { crashed = true; + Log.WriteErrorMessage(LOGTAG, "ServerCrashed", ex, Strings.Program.ServerCrashed(ex.Message)); System.Diagnostics.Trace.WriteLine(Strings.Program.SeriousError(ex.ToString())); if (writeToConsoleOnException) { @@ -350,7 +360,7 @@ namespace Duplicati.Server } finally { - Library.Logging.Log.WriteInformationMessage(LOGTAG, "ServerStopping", Strings.Program.ServerStopping); + Log.WriteInformationMessage(LOGTAG, "ServerStopping", Strings.Program.ServerStopping); var steps = new Action[] { () => StatusEventNotifyer.SignalNewEvent(), @@ -403,7 +413,8 @@ namespace Duplicati.Server parsedOptions.Servername, parsedOptions.AllowedHostnames, parsedOptions.DisableStaticFiles, - parsedOptions.SPAPaths + parsedOptions.SPAPaths, + parsedOptions.CorsOrigins ); var server = DuplicatiWebserver.CreateWebServer(mappedSettings, connection); @@ -603,6 +614,25 @@ namespace Duplicati.Server ); } + if (OperatingSystem.IsWindows() && DataFolder.StartsWith(Util.AppendDirSeparator(Environment.GetFolderPath(Environment.SpecialFolder.Windows)), StringComparison.OrdinalIgnoreCase)) + { + DataConnection.RegisterNotification( + Serialization.NotificationType.Warning, + "Incorrect storage folder", + "The server configuraion is stored inside the Windows folder. Please move the configuration to a different location, or it may be deleted on Windows version upgrades.", + null, + null, + "config:issue:windows-folder-used", + null, + "UnencryptedDatabase", + null, + (self, all) => + { + return all.FirstOrDefault(x => x.Action == "config:issue:windows-folder-used") ?? self; + } + ); + } + DataConnection.ApplicationSettings.LastConfigIssueCheckVersion = UpdaterManager.SelfVersion.Version; } } @@ -659,30 +689,45 @@ namespace Duplicati.Server private static async Task ApplySecretProvider(Dictionary commandlineOptions, CancellationToken cancellationToken) => FIXMEGlobal.SecretProvider = await SecretProviderHelper.ApplySecretProviderAsync([], [], commandlineOptions, TempFolder.SystemTempPath, FIXMEGlobal.SecretProvider, cancellationToken).ConfigureAwait(false); + private class ConsoleLogDestination(LogMessageType level) : ILogDestination + { + public void WriteMessage(LogEntry entry) + { + if (entry.Level >= level) + Console.WriteLine(entry.AsString(true)); + } + } + private static void ConfigureLogging(Dictionary commandlineOptions) { //Log various information in the logfile - if (DEBUG_MODE && !commandlineOptions.ContainsKey("log-file")) + if (DEBUG_MODE && !commandlineOptions.ContainsKey(LOG_FILE_OPTION)) { var prefix = System.Reflection.Assembly.GetEntryAssembly().GetName().Name.StartsWith("Duplicati.Server") ? "server" : "trayicon"; - commandlineOptions["log-file"] = System.IO.Path.Combine(StartupPath, $"Duplicati-{prefix}.debug.log"); - commandlineOptions["log-level"] = Duplicati.Library.Logging.LogMessageType.Profiling.ToString(); - if (System.IO.File.Exists(commandlineOptions["log-file"])) - { - System.IO.File.Delete(commandlineOptions["log-file"]); - } + commandlineOptions[LOG_FILE_OPTION] = System.IO.Path.Combine(StartupPath, $"Duplicati-{prefix}.debug.log"); + commandlineOptions[LOG_LEVEL_OPTION] = Duplicati.Library.Logging.LogMessageType.Profiling.ToString(); + if (System.IO.File.Exists(commandlineOptions[LOG_FILE_OPTION])) + System.IO.File.Delete(commandlineOptions[LOG_FILE_OPTION]); } - // Setup the log redirect - Library.Logging.Log.StartScope(LogHandler, null); + Log.StartScope(LogHandler, null); - if (commandlineOptions.ContainsKey("log-file")) + if (commandlineOptions.ContainsKey(LOG_FILE_OPTION)) { var loglevel = Library.Logging.LogMessageType.Warning; - if (commandlineOptions.ContainsKey("log-level")) - Enum.TryParse(commandlineOptions["log-level"], true, out loglevel); + if (commandlineOptions.ContainsKey(LOG_LEVEL_OPTION)) + Enum.TryParse(commandlineOptions[LOG_LEVEL_OPTION], true, out loglevel); - LogHandler.SetServerFile(commandlineOptions["log-file"], loglevel); + LogHandler.SetServerFile(commandlineOptions[LOG_FILE_OPTION], loglevel); + } + + if (Library.Utility.Utility.ParseBoolOption(commandlineOptions, LOG_CONSOLE_OPTION)) + { + var loglevel = Library.Logging.LogMessageType.Information; + if (commandlineOptions.ContainsKey(LOG_LEVEL_OPTION)) + Enum.TryParse(commandlineOptions[LOG_LEVEL_OPTION], true, out loglevel); + + LogHandler.AppendLogDestination(new ConsoleLogDestination(loglevel), loglevel); } if (commandlineOptions.TryGetValue(WINDOWS_EVENTLOG_OPTION, out var source) && !string.IsNullOrEmpty(source)) @@ -735,43 +780,14 @@ namespace Duplicati.Server throw new Exception("Server invoked with --help"); } - public static string GetDataFolderPath(Dictionary commandlineOptions) + public static Connection GetDatabaseConnection(Dictionary commandlineOptions, bool silentConsole) { - var serverDataFolder = Environment.GetEnvironmentVariable(DATAFOLDER_ENV_NAME); - if (commandlineOptions.ContainsKey("server-datafolder")) - serverDataFolder = commandlineOptions["server-datafolder"]; + DataFolder = DataFolderManager.DATAFOLDER; - if (string.IsNullOrEmpty(serverDataFolder)) - { - bool portableMode = commandlineOptions.ContainsKey("portable-mode") - ? Library.Utility.Utility.ParseBool(commandlineOptions["portable-mode"], true) - : (DEBUG_MODE ? true : false); // Default to portable mode in debug mode + // Emit a warning if the database is stored in the Windows folder + if (Util.IsPathUnderWindowsFolder(DataFolder)) + Log.WriteWarningMessage(LOGTAG, "DatabaseInWindowsFolder", null, "The database is stored in the Windows folder, this is not recommended as it will be deleted on Windows upgrades."); - if (DEBUG_MODE && portableMode) - { - //debug mode uses a lock file located in the app folder - return StartupPath; - } - else if (portableMode) - { - //Portable mode uses a data folder in the application home dir - System.IO.Directory.SetCurrentDirectory(StartupPath); - return System.IO.Path.Combine(StartupPath, "data"); - } - else - { - //Normal release mode uses the systems "(Local) Application Data" folder - // %LOCALAPPDATA% on Windows, ~/.config on Linux, ~/Library/Application\ Support on MacOS - return DatabaseLocator.GetDefaultStorageFolder(SERVER_DATABASE_FILENAME, Library.AutoUpdater.AutoUpdateSettings.AppName); - } - } - else - return Util.AppendDirSeparator(Environment.ExpandEnvironmentVariables(serverDataFolder).Trim('"')); - } - - public static Database.Connection GetDatabaseConnection(Dictionary commandlineOptions, bool silentConsole) - { - DataFolder = GetDataFolderPath(commandlineOptions); CrashlogHelper.DefaultLogDir = DataFolder; var sqliteVersion = new Version(Duplicati.Library.SQLiteHelper.SQLiteLoader.SQLiteVersion); @@ -786,7 +802,7 @@ namespace Duplicati.Server try { - DatabasePath = System.IO.Path.Combine(DataFolder, SERVER_DATABASE_FILENAME); + DatabasePath = System.IO.Path.Combine(DataFolder, DataFolderManager.SERVER_DATABASE_FILENAME); if (!System.IO.Directory.Exists(System.IO.Path.GetDirectoryName(DatabasePath))) System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(DatabasePath)); @@ -961,9 +977,10 @@ namespace Duplicati.Server new Duplicati.Library.Interface.CommandLineArgument("tempdir", Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.TempdirShort, Strings.Program.TempdirLong, System.IO.Path.GetTempPath()), new Duplicati.Library.Interface.CommandLineArgument("help", Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.HelpCommandDescription, Strings.Program.HelpCommandDescription), new Duplicati.Library.Interface.CommandLineArgument("parameters-file", Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.ParametersFileOptionShort, Strings.Program.ParametersFileOptionLong2, "", ParameterFileOptionStrings), - new Duplicati.Library.Interface.CommandLineArgument("portable-mode", Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.PortablemodeCommandDescription, Strings.Program.PortablemodeCommandDescription), - new Duplicati.Library.Interface.CommandLineArgument("log-file", Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.LogfileCommandDescription, Strings.Program.LogfileCommandDescription), - new Duplicati.Library.Interface.CommandLineArgument("log-level", Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Enumeration, Strings.Program.LoglevelCommandDescription, Strings.Program.LoglevelCommandDescription, Library.Logging.LogMessageType.Warning.ToString(), null, Enum.GetNames(typeof(Duplicati.Library.Logging.LogMessageType))), + new Duplicati.Library.Interface.CommandLineArgument(DataFolderManager.PORTABLE_MODE_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.PortablemodeCommandDescription, Strings.Program.PortablemodeCommandDescription, DataFolderManager.PORTABLE_MODE.ToString().ToLowerInvariant()), + new Duplicati.Library.Interface.CommandLineArgument(LOG_FILE_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.LogfileCommandDescription, Strings.Program.LogfileCommandDescription), + new Duplicati.Library.Interface.CommandLineArgument(LOG_LEVEL_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Enumeration, Strings.Program.LoglevelCommandDescription, Strings.Program.LoglevelCommandDescription, Library.Logging.LogMessageType.Warning.ToString(), null, Enum.GetNames(typeof(Duplicati.Library.Logging.LogMessageType))), + new Duplicati.Library.Interface.CommandLineArgument(LOG_CONSOLE_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.LogConsoleDescription, Strings.Program.LogConsoleDescription, false.ToString()), new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBROOT, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.WebserverWebrootDescription, Strings.Program.WebserverWebrootDescription, WebServerLoader.DEFAULT_OPTION_WEBROOT), new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_PORT, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.String, Strings.Program.WebserverPortDescription, Strings.Program.WebserverPortDescription, WebServerLoader.DEFAULT_OPTION_PORT.ToString()), new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_DISABLEHTTPS, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.String, Strings.Program.WebserverDisableHTTPSDescription, Strings.Program.WebserverDisableHTTPSDescription), @@ -980,9 +997,11 @@ namespace Duplicati.Server new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_DISABLE_SIGNIN_TOKENS, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.WebserverDisableSigninTokensDescription, Strings.Program.WebserverDisableSigninTokensDescription), new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_SPAPATHS, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.WebserverSpaPathsDescription, Strings.Program.WebserverSpaPathsDescription, WebServerLoader.DEFAULT_OPTION_SPAPATHS), new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_TIMEZONE, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.String, Strings.Program.WebserverTimezoneDescription, Strings.Program.WebserverTimezoneDescription, TimeZoneHelper.GetLocalTimeZone(), null, TimeZoneHelper.GetTimeZones().Select(x => x.Id).ToArray()), + new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_CORS_ORIGINS, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.WebserverCorsOriginsDescription, Strings.Program.WebserverCorsOriginsDescription, WebServerLoader.DEFAULT_OPTION_SPAPATHS), new Duplicati.Library.Interface.CommandLineArgument(PING_PONG_KEEPALIVE_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.PingpongkeepaliveShort, Strings.Program.PingpongkeepaliveLong), + new Duplicati.Library.Interface.CommandLineArgument(DISABLE_UPDATE_CHECK_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.DisableupdatecheckShort, Strings.Program.DisableupdatecheckLong), new Duplicati.Library.Interface.CommandLineArgument("log-retention", Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Timespan, Strings.Program.LogretentionShort, Strings.Program.LogretentionLong, DEFAULT_LOG_RETENTION), - new Duplicati.Library.Interface.CommandLineArgument("server-datafolder", Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.ServerdatafolderShort, Strings.Program.ServerdatafolderLong(DATAFOLDER_ENV_NAME), System.IO.Path.Combine(System.Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Library.AutoUpdater.AutoUpdateSettings.AppName)), + new Duplicati.Library.Interface.CommandLineArgument(DataFolderManager.SERVER_DATAFOLDER_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.ServerdatafolderShort, Strings.Program.ServerdatafolderLong(DataFolderManager.DATAFOLDER_ENV_NAME), DataFolderManager.DATAFOLDER), new Duplicati.Library.Interface.CommandLineArgument(DISABLE_DB_ENCRYPTION_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.DisabledbencryptionShort, Strings.Program.DisabledbencryptionLong), new Duplicati.Library.Interface.CommandLineArgument(REQUIRE_DB_ENCRYPTION_KEY_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.RequiredbencryptionShort, Strings.Program.RequiredbencryptionLong), new Duplicati.Library.Interface.CommandLineArgument(SETTINGS_ENCRYPTION_KEY_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Password, Strings.Program.SettingsencryptionkeyShort, Strings.Program.SettingsencryptionkeyLong(EncryptedFieldHelper.ENVIROMENT_VARIABLE_NAME)), diff --git a/Duplicati/Server/WebServerLoader.cs b/Duplicati/Server/WebServerLoader.cs index 7f14dd5d0..3b82cf767 100644 --- a/Duplicati/Server/WebServerLoader.cs +++ b/Duplicati/Server/WebServerLoader.cs @@ -99,6 +99,10 @@ public static class WebServerLoader /// public const string OPTION_WEBSERVICE_SPAPATHS = "webservice-spa-paths"; /// + /// The CORS origins to allow + /// + public const string OPTION_WEBSERVICE_CORS_ORIGINS = "webservice-cors-origins"; + /// /// Option for setting the webservice timezone /// public const string OPTION_WEBSERVICE_TIMEZONE = "webservice-timezone"; @@ -154,6 +158,7 @@ public static class WebServerLoader /// The allowed hostnames /// If static files should be disabled /// The paths to serve as SPAs + /// The origins to allow for CORS public record ParsedWebserverSettings( string WebRoot, int Port, @@ -162,7 +167,8 @@ public static class WebServerLoader string Servername, IEnumerable AllowedHostnames, bool DisableStaticFiles, - IEnumerable SPAPaths + IEnumerable SPAPaths, + IEnumerable CorsOrigins ); @@ -251,7 +257,8 @@ public static class WebServerLoader string.Format("{0} v{1}", Library.AutoUpdater.AutoUpdateSettings.AppName, Library.AutoUpdater.UpdaterManager.SelfVersion.Version), (connection.ApplicationSettings.AllowedHostnames ?? string.Empty).Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries), Duplicati.Library.Utility.Utility.ParseBoolOption(options, OPTION_WEBSERVICE_API_ONLY), - spaPathsString.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + spaPathsString.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries), + options.GetValueOrDefault(OPTION_WEBSERVICE_CORS_ORIGINS)?.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Enumerable.Empty() ); // Materialize the list of ports, and move the last-used port to the front, so we try the last-known port first diff --git a/Duplicati/Server/webroot/index.html b/Duplicati/Server/webroot/index.html index 5502698e9..d979ecc54 100644 --- a/Duplicati/Server/webroot/index.html +++ b/Duplicati/Server/webroot/index.html @@ -23,7 +23,7 @@ var d = new Date(); d.setTime(d.getTime() + (90*24*60*60*1000)); - document.cookie = 'default-theme=' + theme + '; expires=' + d.toUTCString() + '; path=/'; + document.cookie = 'default-client=' + theme + '; expires=' + d.toUTCString() + '; path=/'; // Make an absolute path redirect, as relative redirects break if the port is non-standard var path = window.location.pathname; @@ -38,11 +38,9 @@ window.location.replace(path); } - var theme = parseCookies()['default-theme']; + var theme = parseCookies()['default-client']; if ((theme || '').trim().length == 0) - theme = 'ngax'; - if (theme == 'greeno') // Make sure users are redirected automatically - theme = 'ngax'; + theme = 'ngclient'; setCookieAndRun(theme); // Refresh diff --git a/Duplicati/Server/webroot/ngax/index.html b/Duplicati/Server/webroot/ngax/index.html index bdc81a44e..5f72a20fa 100755 --- a/Duplicati/Server/webroot/ngax/index.html +++ b/Duplicati/Server/webroot/ngax/index.html @@ -258,6 +258,10 @@ + + diff --git a/Duplicati/Server/webroot/ngax/templates/notificationarea.html b/Duplicati/Server/webroot/ngax/templates/notificationarea.html index d5904747d..60e4a9523 100644 --- a/Duplicati/Server/webroot/ngax/templates/notificationarea.html +++ b/Duplicati/Server/webroot/ngax/templates/notificationarea.html @@ -14,6 +14,9 @@ Repair Download + + Show help + Show help
diff --git a/Duplicati/Server/webroot/ngax/templates/settings.html b/Duplicati/Server/webroot/ngax/templates/settings.html index abc79760c..d3a116aab 100644 --- a/Duplicati/Server/webroot/ngax/templates/settings.html +++ b/Duplicati/Server/webroot/ngax/templates/settings.html @@ -138,7 +138,7 @@
-
Try out the new features that we are working on. Currently the most stable version available. Test Restore data before using this in production environments.
+
Try out the new features that we are working on. Test Backup & Restore before using this in production environments.
diff --git a/Duplicati/Server/webroot/ngclient/package-lock.json b/Duplicati/Server/webroot/ngclient/package-lock.json index 5e823b2ba..34f830ffb 100644 --- a/Duplicati/Server/webroot/ngclient/package-lock.json +++ b/Duplicati/Server/webroot/ngclient/package-lock.json @@ -9,13 +9,13 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@duplicati/ngclient": "^0.0.57" + "@duplicati/ngclient": "^0.0.97" } }, "node_modules/@duplicati/ngclient": { - "version": "0.0.57", - "resolved": "https://registry.npmjs.org/@duplicati/ngclient/-/ngclient-0.0.57.tgz", - "integrity": "sha512-w38h07j1KJkqwD2VsLE8i+EQIpsjbw5uNHH+Utho/wzY0tcuRYgN44GJ6O0UEEx9rFRK5ecz+T40wpZQGIEOkw==", + "version": "0.0.97", + "resolved": "https://registry.npmjs.org/@duplicati/ngclient/-/ngclient-0.0.97.tgz", + "integrity": "sha512-4c0RCVfBk3WwqDeK5mYc6NwhEwnM1UQLtfD5n7FoMs0i6NTFkRBkKX35bgxFZK2ujLONDylt/hkR8FxZNiMxAA==", "license": "MIT" } } diff --git a/Duplicati/Server/webroot/ngclient/package.json b/Duplicati/Server/webroot/ngclient/package.json index 9f5165ab8..c64d2fd23 100644 --- a/Duplicati/Server/webroot/ngclient/package.json +++ b/Duplicati/Server/webroot/ngclient/package.json @@ -4,6 +4,6 @@ "description": "Modern Angular Client for Duplicati", "license": "MIT", "dependencies": { - "@duplicati/ngclient": "^0.0.57" + "@duplicati/ngclient": "^0.0.97" } } diff --git a/Duplicati/Server/webroot/theme.html b/Duplicati/Server/webroot/theme.html index f3dc3e60a..08bbb54bd 100644 --- a/Duplicati/Server/webroot/theme.html +++ b/Duplicati/Server/webroot/theme.html @@ -49,8 +49,8 @@ return cookies; } - function updateToCurrentTheme() { - var theme = parseCookies()["default-theme"]; + function updateToCurrentClient() { + var theme = parseCookies()["default-client"]; var themes = ["ngax", "ngclient"]; for (var k in themes) { @@ -68,7 +68,7 @@ } // Lazy hack for DOM timing issues... - window.setTimeout(updateToCurrentTheme, 1000); + window.setTimeout(updateToCurrentClient, 1000); @@ -99,7 +99,7 @@ diff --git a/Duplicati/Service/Runner.cs b/Duplicati/Service/Runner.cs index c6327d822..a406fe1a7 100644 --- a/Duplicati/Service/Runner.cs +++ b/Duplicati/Service/Runner.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -117,6 +117,11 @@ namespace Duplicati.Service PingProcess(); } } + + if (m_process.ExitCode != 0) + m_reportMessage(string.Format("Process has exited with code {0}", m_process.ExitCode), true); + else if (!m_terminate) + m_reportMessage("Process has exited without an error code", true); } catch (Exception ex) { @@ -131,11 +136,11 @@ namespace Duplicati.Service m_terminate = true; } } - - // Throttle restarts - if (!m_terminate) - System.Threading.Thread.Sleep(TimeSpan.FromSeconds(10)); } + + // Throttle restarts + if (!m_terminate) + System.Threading.Thread.Sleep(TimeSpan.FromSeconds(10)); } } finally diff --git a/Duplicati/Tools/Duplicati.Tools.csproj b/Duplicati/Tools/Duplicati.Tools.csproj index 0e771a6e5..ee68d0b16 100644 --- a/Duplicati/Tools/Duplicati.Tools.csproj +++ b/Duplicati/Tools/Duplicati.Tools.csproj @@ -5,7 +5,7 @@ Library Copyright © 2025 Team Duplicati, MIT license - + utility-scripts\DuplicatiVerify.ps1 @@ -24,12 +24,12 @@ utility-scripts\pause-resume.py - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Duplicati/UnitTest/BorderTests.cs b/Duplicati/UnitTest/BorderTests.cs index 0e4bc93fc..e03f8b10f 100644 --- a/Duplicati/UnitTest/BorderTests.cs +++ b/Duplicati/UnitTest/BorderTests.cs @@ -1,22 +1,22 @@ // Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the +// +// 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 +// +// 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 +// +// 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; @@ -54,6 +54,7 @@ namespace Duplicati.UnitTest RunCommands(1024 * 10, modifyOptions: opts => { opts["blocksize"] = "10mb"; + opts["dblock-size"] = "25mb"; }); } @@ -270,6 +271,7 @@ namespace Duplicati.UnitTest File.WriteAllBytes(Path.Combine(DATAFOLDER, "c" + k.Key), data.Take(k.Value).ToArray()); } + System.Threading.Tasks.Task.Delay(1000).Wait(); using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null)) { diff --git a/Duplicati/UnitTest/CompactDisruptionTests.cs b/Duplicati/UnitTest/CompactDisruptionTests.cs index 38da492cf..8a8f9328c 100644 --- a/Duplicati/UnitTest/CompactDisruptionTests.cs +++ b/Duplicati/UnitTest/CompactDisruptionTests.cs @@ -135,13 +135,13 @@ namespace Duplicati.UnitTest target = new DeterministicErrorBackend().ProtocolKey + "://" + TARGETFOLDER; // Fail the compact after the first dblock put is completed bool firstPutCompleted = false; - DeterministicErrorBackend.ErrorGenerator = (string action, string remotename) => + DeterministicErrorBackend.ErrorGenerator = (DeterministicErrorBackend.BackendAction action, string remotename) => { - if (firstPutCompleted && (action == "get_0" || action == "get_1")) + if (firstPutCompleted && action.IsGetOperation) { return true; } - if (action == "put_1" || action == "put_async") + if (action == DeterministicErrorBackend.BackendAction.PutAfter) { firstPutCompleted = true; } @@ -250,13 +250,13 @@ namespace Duplicati.UnitTest target = new DeterministicErrorBackend().ProtocolKey + "://" + TARGETFOLDER; // Fail the compact after the first dblock put is completed bool firstPutCompleted = false; - DeterministicErrorBackend.ErrorGenerator = (string action, string remotename) => + DeterministicErrorBackend.ErrorGenerator = (DeterministicErrorBackend.BackendAction action, string remotename) => { - if (firstPutCompleted && (action == "get_0" || action == "get_1")) + if (firstPutCompleted && action.IsGetOperation) { return true; } - if (action == "put_1" || action == "put_async") + if (action == DeterministicErrorBackend.BackendAction.PutAfter) { firstPutCompleted = true; } @@ -382,13 +382,13 @@ namespace Duplicati.UnitTest target = new DeterministicErrorBackend().ProtocolKey + "://" + TARGETFOLDER; // Fail the compact after the first dblock put is completed bool firstPutCompleted = false; - DeterministicErrorBackend.ErrorGenerator = (string action, string remotename) => + DeterministicErrorBackend.ErrorGenerator = (DeterministicErrorBackend.BackendAction action, string remotename) => { - if (firstPutCompleted && (action == "get_0" || action == "get_1")) + if (firstPutCompleted && action.IsGetOperation) { return true; } - if (action == "put_1" || action == "put_async") + if (action == DeterministicErrorBackend.BackendAction.PutAfter) { firstPutCompleted = true; } @@ -523,13 +523,13 @@ namespace Duplicati.UnitTest target = new DeterministicErrorBackend().ProtocolKey + "://" + TARGETFOLDER; // Fail the compact after the first dblock put is completed bool firstPutCompleted = false; - DeterministicErrorBackend.ErrorGenerator = (string action, string remotename) => + DeterministicErrorBackend.ErrorGenerator = (DeterministicErrorBackend.BackendAction action, string remotename) => { - if (firstPutCompleted && (action == "get_0" || action == "get_1")) + if (firstPutCompleted && action.IsGetOperation) { return true; } - if (action == "put_1" || action == "put_async") + if (action == DeterministicErrorBackend.BackendAction.PutAfter) { firstPutCompleted = true; } diff --git a/Duplicati/UnitTest/DeterministicErrorBackend.cs b/Duplicati/UnitTest/DeterministicErrorBackend.cs index 34a62ad7e..a545d1b50 100644 --- a/Duplicati/UnitTest/DeterministicErrorBackend.cs +++ b/Duplicati/UnitTest/DeterministicErrorBackend.cs @@ -31,14 +31,26 @@ namespace Duplicati.UnitTest { public class DeterministicErrorBackend : IBackend, IStreamingBackend { + public sealed record BackendAction(string Code) + { + public static readonly BackendAction GetBefore = new BackendAction("get_0"); + public static readonly BackendAction GetAfter = new BackendAction("get_1"); + public static readonly BackendAction PutBefore = new BackendAction("put_0"); + public static readonly BackendAction PutAfter = new BackendAction("put_1"); + public static readonly BackendAction DeleteBefore = new BackendAction("delete_0"); + public static readonly BackendAction DeleteAfter = new BackendAction("delete_1"); + + public bool IsGetOperation => this == GetBefore || this == GetAfter; + public bool IsPutOperation => this == PutBefore || this == PutAfter; + public bool IsDeleteOperation => this == DeleteBefore || this == DeleteAfter; + } + public class DeterministicErrorBackendException(string message) : Exception(message) { }; static DeterministicErrorBackend() { WrappedBackend = "file"; } - private static readonly Random random = new Random(42); - // return true to throw exception, parameters: (action, remotename) - public static Func ErrorGenerator = null; + public static Func ErrorGenerator = null; public static string WrappedBackend { get; set; } @@ -54,7 +66,7 @@ namespace Duplicati.UnitTest m_backend = (IStreamingBackend)Library.DynamicLoader.BackendLoader.GetBackend(u, options); } - private void ThrowError(string action, string remotename) + private void ThrowError(BackendAction action, string remotename) { if (ErrorGenerator != null && ErrorGenerator(action, remotename)) { @@ -64,42 +76,40 @@ namespace Duplicati.UnitTest #region IStreamingBackend implementation public async Task PutAsync(string remotename, Stream stream, CancellationToken cancelToken) { - var uploadError = random.NextDouble() > 0.9; - - using (var f = new Library.Utility.ProgressReportingStream(stream, x => { if (uploadError && stream.Position > stream.Length / 2) throw new DeterministicErrorBackendException("Random upload failure"); })) - await m_backend.PutAsync(remotename, f, cancelToken).ConfigureAwait(false); - ThrowError("put_async", remotename); + ThrowError(BackendAction.PutBefore, remotename); + await m_backend.PutAsync(remotename, stream, cancelToken).ConfigureAwait(false); + ThrowError(BackendAction.PutAfter, remotename); } public async Task GetAsync(string remotename, Stream stream, CancellationToken cancellationToken) { - ThrowError("get_0", remotename); + ThrowError(BackendAction.GetBefore, remotename); await m_backend.GetAsync(remotename, stream, cancellationToken).ConfigureAwait(false); - ThrowError("get_1", remotename); + ThrowError(BackendAction.GetAfter, remotename); } #endregion #region IBackend implementation - public IEnumerable List() + public IAsyncEnumerable ListAsync(CancellationToken cancelToken) { - return m_backend.List(); + return m_backend.ListAsync(cancelToken); } public async Task PutAsync(string remotename, string filename, CancellationToken cancelToken) { - ThrowError("put_0", remotename); + ThrowError(BackendAction.PutBefore, remotename); await m_backend.PutAsync(remotename, filename, cancelToken).ConfigureAwait(false); - ThrowError("put_1", remotename); + ThrowError(BackendAction.PutAfter, remotename); } public async Task GetAsync(string remotename, string filename, CancellationToken cancelToken) { - ThrowError("get_0", remotename); + ThrowError(BackendAction.GetBefore, remotename); await m_backend.GetAsync(remotename, filename, cancelToken).ConfigureAwait(false); - ThrowError("get_1", remotename); + ThrowError(BackendAction.GetAfter, remotename); } public async Task DeleteAsync(string remotename, CancellationToken cancelToken) { - ThrowError("delete_0", remotename); + ThrowError(BackendAction.DeleteBefore, remotename); await m_backend.DeleteAsync(remotename, cancelToken).ConfigureAwait(false); - ThrowError("delete_1", remotename); + ThrowError(BackendAction.DeleteAfter, remotename); } public Task TestAsync(CancellationToken cancelToken) { diff --git a/Duplicati/UnitTest/DisruptionTests.cs b/Duplicati/UnitTest/DisruptionTests.cs index c23a15124..9618951cf 100644 --- a/Duplicati/UnitTest/DisruptionTests.cs +++ b/Duplicati/UnitTest/DisruptionTests.cs @@ -25,6 +25,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Duplicati.Library.Interface; +using Duplicati.Library.Logging; using Duplicati.Library.Main; using Duplicati.Library.Main.Database; using Duplicati.Library.Main.Volumes; @@ -50,11 +51,12 @@ namespace Duplicati.UnitTest } } - private async Task RunPartialBackup(Controller controller) + private async Task<(IBackupResults Result, List ModifiedFiles)> RunPartialBackup(Controller controller) { this.ModifySourceFiles(); var stopped = new TaskCompletionSource(); + var modifiedFiles = new List(); controller.OnOperationStarted = r => { var pv = r as ITaskControlProvider; @@ -63,7 +65,8 @@ namespace Duplicati.UnitTest #if DEBUG pv.TaskControl.TestMethodCallback = (path) => { - if (path.EndsWith(this.fileSizes[2] + "MB")) + modifiedFiles.Add(path); + if (modifiedFiles.Count == 3) { Thread.Sleep(500); controller.Stop(); @@ -80,7 +83,7 @@ namespace Duplicati.UnitTest if (t != stopped.Task) throw new Exception("Backup task completed before we could stop it"); - return await backupTask.ConfigureAwait(false); + return (await backupTask.ConfigureAwait(false), modifiedFiles); } [SetUp] @@ -121,7 +124,7 @@ namespace Duplicati.UnitTest // Run a partial backup. using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { - IBackupResults backupResults = await this.RunPartialBackup(c).ConfigureAwait(false); + (var backupResults, _) = await this.RunPartialBackup(c).ConfigureAwait(false); Assert.AreEqual(0, backupResults.Errors.Count()); Assert.AreEqual(1, backupResults.Warnings.Count()); } @@ -152,9 +155,7 @@ namespace Duplicati.UnitTest Dictionary backupTypeMap; using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { - IPurgeFilesResults purgeResults = c.PurgeFiles(new Library.Utility.FilterExpression($"{this.DATAFOLDER}/*{this.fileSizes[0]}*")); - Assert.AreEqual(0, purgeResults.Errors.Count()); - Assert.AreEqual(0, purgeResults.Warnings.Count()); + TestUtils.AssertResults(c.PurgeFiles(new Library.Utility.FilterExpression($"{this.DATAFOLDER}/*{this.fileSizes[0]}*"))); List filesets = c.List().Filesets.ToList(); Assert.AreEqual(2, filesets.Count); @@ -178,9 +179,7 @@ namespace Duplicati.UnitTest // Run a repair and verify that the fileset file exists in the new dlist files. using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { - IRepairResults repairResults = c.Repair(); - Assert.AreEqual(0, repairResults.Errors.Count()); - Assert.AreEqual(0, repairResults.Warnings.Count()); + TestUtils.AssertResults(c.Repair()); List filesets = c.List().Filesets.ToList(); Assert.AreEqual(2, filesets.Count); @@ -217,9 +216,7 @@ namespace Duplicati.UnitTest DateTime firstBackupTime; using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { - IBackupResults backupResults = c.Backup(new[] { this.DATAFOLDER }); - Assert.AreEqual(0, backupResults.Errors.Count()); - Assert.AreEqual(0, backupResults.Warnings.Count()); + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); firstBackupTime = c.List().Filesets.First().Time; } @@ -230,9 +227,7 @@ namespace Duplicati.UnitTest using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { this.ModifySourceFiles(); - IBackupResults backupResults = c.Backup(new[] { this.DATAFOLDER }); - Assert.AreEqual(0, backupResults.Errors.Count()); - Assert.AreEqual(0, backupResults.Warnings.Count()); + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); secondBackupTime = c.List().Filesets.First().Time; } @@ -240,7 +235,7 @@ namespace Duplicati.UnitTest DateTime thirdBackupTime; using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { - IBackupResults backupResults = await this.RunPartialBackup(c).ConfigureAwait(false); + (var backupResults, _) = await this.RunPartialBackup(c).ConfigureAwait(false); Assert.AreEqual(0, backupResults.Errors.Count()); Assert.AreEqual(1, backupResults.Warnings.Count()); thirdBackupTime = c.List().Filesets.First().Time; @@ -251,9 +246,7 @@ namespace Duplicati.UnitTest using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { options["keep-time"] = $"{(int)((DateTime.Now - firstBackupTime).TotalSeconds - (secondBackupTime - firstBackupTime).TotalSeconds / 2)}s"; - IDeleteResults deleteResults = c.Delete(); - Assert.AreEqual(0, deleteResults.Errors.Count()); - Assert.AreEqual(0, deleteResults.Warnings.Count()); + TestUtils.AssertResults(c.Delete()); List filesets = c.List().Filesets.ToList(); Assert.AreEqual(2, filesets.Count); @@ -267,7 +260,7 @@ namespace Duplicati.UnitTest // even when all the "recent" backups are partial. using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { - IBackupResults backupResults = await this.RunPartialBackup(c).ConfigureAwait(false); + (var backupResults, _) = await this.RunPartialBackup(c).ConfigureAwait(false); Assert.AreEqual(0, backupResults.Errors.Count()); Assert.AreEqual(1, backupResults.Warnings.Count()); DateTime fourthBackupTime = c.List().Filesets.First().Time; @@ -275,9 +268,7 @@ namespace Duplicati.UnitTest // Set the keep-time option so that the threshold lies after the most recent full backup // and run the delete operation. options["keep-time"] = "1s"; - IDeleteResults deleteResults = c.Delete(); - Assert.AreEqual(0, deleteResults.Errors.Count()); - Assert.AreEqual(0, deleteResults.Warnings.Count()); + TestUtils.AssertResults(c.Delete()); List filesets = c.List().Filesets.ToList(); Assert.AreEqual(3, filesets.Count); @@ -310,16 +301,14 @@ namespace Duplicati.UnitTest DateTime firstBackupTime; using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { - IBackupResults backupResults = c.Backup(new[] { this.DATAFOLDER }); - Assert.AreEqual(0, backupResults.Errors.Count()); - Assert.AreEqual(0, backupResults.Warnings.Count()); + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); firstBackupTime = c.List().Filesets.First().Time; } // Run a partial backup. using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { - IBackupResults backupResults = await this.RunPartialBackup(c).ConfigureAwait(false); + (var backupResults, _) = await this.RunPartialBackup(c).ConfigureAwait(false); Assert.AreEqual(0, backupResults.Errors.Count()); Assert.AreEqual(1, backupResults.Warnings.Count()); } @@ -327,7 +316,7 @@ namespace Duplicati.UnitTest // Run a partial backup. using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { - IBackupResults backupResults = await this.RunPartialBackup(c).ConfigureAwait(false); + (var backupResults, _) = await this.RunPartialBackup(c).ConfigureAwait(false); Assert.AreEqual(0, backupResults.Errors.Count()); Assert.AreEqual(1, backupResults.Warnings.Count()); } @@ -337,9 +326,7 @@ namespace Duplicati.UnitTest using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { this.ModifySourceFiles(); - IBackupResults backupResults = c.Backup(new[] { this.DATAFOLDER }); - Assert.AreEqual(0, backupResults.Errors.Count()); - Assert.AreEqual(0, backupResults.Warnings.Count()); + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); fourthBackupTime = c.List().Filesets.First().Time; } @@ -347,7 +334,7 @@ namespace Duplicati.UnitTest using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { options["keep-versions"] = "2"; - IBackupResults backupResults = await this.RunPartialBackup(c).ConfigureAwait(false); + (var backupResults, _) = await this.RunPartialBackup(c).ConfigureAwait(false); Assert.AreEqual(0, backupResults.Errors.Count()); Assert.AreEqual(1, backupResults.Warnings.Count()); DateTime fifthBackupTime = c.List().Filesets.First().Time; @@ -370,9 +357,7 @@ namespace Duplicati.UnitTest using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { this.ModifySourceFiles(); - IBackupResults backupResults = c.Backup(new[] { this.DATAFOLDER }); - Assert.AreEqual(0, backupResults.Errors.Count()); - Assert.AreEqual(0, backupResults.Warnings.Count()); + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); DateTime sixthBackupTime = c.List().Filesets.First().Time; // Since the last backup was full, we can now expect to have just the 2 most recent full backups. @@ -404,16 +389,12 @@ namespace Duplicati.UnitTest // Run a full backup. using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) - { - IBackupResults backupResults = c.Backup(new[] { this.DATAFOLDER }); - Assert.AreEqual(0, backupResults.Errors.Count()); - Assert.AreEqual(0, backupResults.Warnings.Count()); - } + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); // Run a partial backup. using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { - IBackupResults backupResults = await this.RunPartialBackup(c).ConfigureAwait(false); + (var backupResults, _) = await this.RunPartialBackup(c).ConfigureAwait(false); Assert.AreEqual(0, backupResults.Errors.Count()); Assert.AreEqual(1, backupResults.Warnings.Count()); @@ -447,18 +428,14 @@ namespace Duplicati.UnitTest DateTime firstBackupTime; using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { - IBackupResults backupResults = c.Backup(new[] { this.DATAFOLDER }); - Assert.AreEqual(0, backupResults.Errors.Count()); - Assert.AreEqual(0, backupResults.Warnings.Count()); + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); firstBackupTime = c.List().Filesets.First().Time; List filesets = c.List().Filesets.ToList(); Assert.AreEqual(1, filesets.Count); this.ModifySourceFiles(); - backupResults = c.Backup(new[] { this.DATAFOLDER }); - Assert.AreEqual(0, backupResults.Errors.Count()); - Assert.AreEqual(0, backupResults.Warnings.Count()); + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); DateTime secondBackupTime = c.List().Filesets.First().Time; // Since the most recent backup is not considered in the retention logic, the only backup in the first time frame @@ -477,7 +454,7 @@ namespace Duplicati.UnitTest DateTime thirdBackupTime; using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { - IBackupResults backupResults = await this.RunPartialBackup(c).ConfigureAwait(false); + (var backupResults, _) = await this.RunPartialBackup(c).ConfigureAwait(false); Assert.AreEqual(0, backupResults.Errors.Count()); Assert.AreEqual(1, backupResults.Warnings.Count()); thirdBackupTime = c.List().Filesets.First().Time; @@ -500,9 +477,7 @@ namespace Duplicati.UnitTest using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { this.ModifySourceFiles(); - IBackupResults backupResults = c.Backup(new[] { this.DATAFOLDER }); - Assert.AreEqual(0, backupResults.Errors.Count()); - Assert.AreEqual(0, backupResults.Warnings.Count()); + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); fourthBackupTime = c.List().Filesets.First().Time; // Since the most recent backup is not considered in the retention logic, the third backup is the only backup @@ -518,9 +493,7 @@ namespace Duplicati.UnitTest Assert.AreEqual(BackupType.FULL_BACKUP, filesets[0].IsFullBackup); this.ModifySourceFiles(); - backupResults = c.Backup(new[] { this.DATAFOLDER }); - Assert.AreEqual(0, backupResults.Errors.Count()); - Assert.AreEqual(0, backupResults.Warnings.Count()); + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); DateTime fifthBackupTime = c.List().Filesets.First().Time; // Since the most recent backup is not considered in the retention logic, we now have two backups in the @@ -585,9 +558,7 @@ namespace Duplicati.UnitTest // Run a complete backup. using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { - IBackupResults backupResults = c.Backup(new[] { this.DATAFOLDER }); - Assert.AreEqual(0, backupResults.Errors.Count()); - Assert.AreEqual(0, backupResults.Warnings.Count()); + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); Assert.AreEqual(1, c.List().Filesets.Count()); Assert.AreEqual(BackupType.FULL_BACKUP, c.List().Filesets.Single(x => x.Version == 0).IsFullBackup); @@ -596,9 +567,11 @@ namespace Duplicati.UnitTest // Run a partial backup. using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { - IBackupResults backupResults = await this.RunPartialBackup(c).ConfigureAwait(false); + (var backupResults, var modified) = await this.RunPartialBackup(c).ConfigureAwait(false); Assert.AreEqual(0, backupResults.Errors.Count()); Assert.AreEqual(1, backupResults.Warnings.Count()); + if (backupResults.ModifiedFiles == 0) + throw new Exception($"No files were modified, likely the stop was issued too early, list is: {string.Join(", ", modified)}"); Assert.That(backupResults.ModifiedFiles, Is.GreaterThan(0), "No files were added, likely the stop was issued too early"); Assert.That(backupResults.ModifiedFiles, Is.LessThan(fileSizes.Length), "All files were added, likely the stop was issued too late"); @@ -612,14 +585,14 @@ namespace Duplicati.UnitTest Dictionary restoreOptions = new Dictionary(options) { ["restore-path"] = this.RESTOREFOLDER }; using (Controller c = new Controller("file://" + this.TARGETFOLDER, restoreOptions, null)) { - IListResults lastResults = c.List("*"); - string[] partialVersionFiles = lastResults.Files.Select(x => x.Path).Where(x => !Utility.IsFolder(x, File.GetAttributes)).ToArray(); + var lastResults = c.List("*"); + var partialVersionFiles = lastResults.Files.Select(x => x.Path).Where(x => !Utility.IsFolder(x, File.GetAttributes)).ToArray(); Assert.GreaterOrEqual(partialVersionFiles.Length, 1); c.Restore(partialVersionFiles); foreach (string filepath in partialVersionFiles) { - string filename = Path.GetFileName(filepath); + var filename = Path.GetFileName(filepath); TestUtils.AssertFilesAreEqual(filepath, Path.Combine(this.RESTOREFOLDER, filename ?? String.Empty), false, filename); } } @@ -628,9 +601,7 @@ namespace Duplicati.UnitTest File.Delete(this.DBFILE); using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { - IRepairResults repairResults = c.Repair(); - Assert.AreEqual(0, repairResults.Errors.Count()); - Assert.AreEqual(0, repairResults.Warnings.Count()); + TestUtils.AssertResults(c.Repair()); Assert.AreEqual(2, c.List().Filesets.Count()); Assert.AreEqual(BackupType.FULL_BACKUP, c.List().Filesets.Single(x => x.Version == 1).IsFullBackup); @@ -640,9 +611,7 @@ namespace Duplicati.UnitTest // Run a complete backup. Listing the Filesets should include both full and partial backups. using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { - IBackupResults backupResults = c.Backup(new[] { this.DATAFOLDER }); - Assert.AreEqual(0, backupResults.Errors.Count()); - Assert.AreEqual(0, backupResults.Warnings.Count()); + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); Assert.AreEqual(3, c.List().Filesets.Count()); Assert.AreEqual(BackupType.FULL_BACKUP, c.List().Filesets.Single(x => x.Version == 2).IsFullBackup); @@ -654,13 +623,11 @@ namespace Duplicati.UnitTest restoreOptions["overwrite"] = "true"; using (Controller c = new Controller("file://" + this.TARGETFOLDER, restoreOptions, null)) { - IListResults lastResults = c.List("*"); - string[] fullVersionFiles = lastResults.Files.Select(x => x.Path).Where(x => !Utility.IsFolder(x, File.GetAttributes)).ToArray(); + var lastResults = c.List("*"); + var fullVersionFiles = lastResults.Files.Select(x => x.Path).Where(x => !Utility.IsFolder(x, File.GetAttributes)).ToArray(); Assert.AreEqual(this.fileSizes.Length, fullVersionFiles.Length); - IRestoreResults restoreResults = c.Restore(fullVersionFiles); - Assert.AreEqual(0, restoreResults.Errors.Count()); - Assert.AreEqual(0, restoreResults.Warnings.Count()); + TestUtils.AssertResults(c.Restore(fullVersionFiles)); foreach (string filepath in fullVersionFiles) { @@ -690,9 +657,7 @@ namespace Duplicati.UnitTest // Run a complete backup. using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { - IBackupResults backupResults = c.Backup(new[] { this.DATAFOLDER }); - Assert.AreEqual(0, backupResults.Errors.Count()); - Assert.AreEqual(0, backupResults.Warnings.Count()); + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); List filesets = c.List().Filesets.ToList(); Assert.AreEqual(1, filesets.Count); @@ -703,29 +668,42 @@ namespace Duplicati.UnitTest this.ModifySourceFiles(); using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { - // ReSharper disable once AccessToDisposedClosure - Task backupTask = Task.Run(() => c.Backup(new[] { this.DATAFOLDER })); - - // Block for a small amount of time to allow the ITaskControl to be associated - // with the Controller. Otherwise, the call to Stop will simply be a no-op. - Thread.Sleep(1000); - - c.Abort(); - try + var startedTcs = new TaskCompletionSource(); + c.OnOperationStarted += results => { - await backupTask.ConfigureAwait(false); + ((BackupResults)results).OperationProgressUpdater.PhaseChanged += (phase, previousPhase) => + { + if (phase == OperationPhase.Backup_ProcessingFiles) + startedTcs.SetResult(true); + }; + }; + + Task backupTask = Task.Run(() => c.Backup([DATAFOLDER])); + + Task completedTask = await Task.WhenAny(startedTcs.Task, Task.Delay(5000)); + if (completedTask == startedTcs.Task) + { + c.Abort(); + try + { + await backupTask.ConfigureAwait(false); + Assert.Fail("Expected OperationCanceledException but none was thrown."); + } + catch (OperationCanceledException) + { + // Success, exception thrown as expected + } } - catch (TaskCanceledException) + else { + Assert.Fail("Operation did not reach ready state within timeout."); } } - + // The next backup should proceed without issues. using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) { - IBackupResults backupResults = c.Backup(new[] { this.DATAFOLDER }); - Assert.AreEqual(0, backupResults.Errors.Count()); - Assert.AreEqual(0, backupResults.Warnings.Count()); + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); List filesets = c.List().Filesets.ToList(); Assert.AreEqual(2, filesets.Count); @@ -737,14 +715,11 @@ namespace Duplicati.UnitTest Dictionary restoreOptions = new Dictionary(options) { ["restore-path"] = this.RESTOREFOLDER }; using (Controller c = new Controller("file://" + this.TARGETFOLDER, restoreOptions, null)) { - IListResults lastResults = c.List("*"); - string[] fullVersionFiles = lastResults.Files.Select(x => x.Path).Where(x => !Utility.IsFolder(x, File.GetAttributes)).ToArray(); + var lastResults = c.List("*"); + var fullVersionFiles = lastResults.Files.Select(x => x.Path).Where(x => !Utility.IsFolder(x, File.GetAttributes)).ToArray(); Assert.AreEqual(this.fileSizes.Length, fullVersionFiles.Length); - IRestoreResults restoreResults = c.Restore(fullVersionFiles); - Assert.AreEqual(0, restoreResults.Errors.Count()); - Assert.AreEqual(0, restoreResults.Warnings.Count()); - + TestUtils.AssertResults(c.Restore(fullVersionFiles)); foreach (string filepath in fullVersionFiles) { string filename = Path.GetFileName(filepath); @@ -752,5 +727,552 @@ namespace Duplicati.UnitTest } } } + + [Test] + [Category("Disruption")] + public void TestUploadExceptionWithNoRetries() + { + var testopts = TestOptions; + testopts["number-of-retries"] = "0"; + testopts["dblock-size"] = "10mb"; + + // Make a base backup + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + TestUtils.AssertResults(c.Backup(new string[] { DATAFOLDER })); + + // Make a new backup that fails uploading a dblock file + ModifySourceFiles(); + + // Deterministic error backend + Library.DynamicLoader.BackendLoader.AddBackend(new DeterministicErrorBackend()); + var failtarget = new DeterministicErrorBackend().ProtocolKey + "://" + TARGETFOLDER; + + var hasFailed = false; + var secondUploadStarted = false; + var secondUploadCompleted = false; + + // Fail the compact after the first dblock put is completed + DeterministicErrorBackend.ErrorGenerator = (DeterministicErrorBackend.BackendAction action, string remotename) => + { + if (action.IsGetOperation) + { + return true; + } + + if (!hasFailed && action == DeterministicErrorBackend.BackendAction.PutBefore) + { + // We only fail one upload, but there are no retries + hasFailed = true; + + // Make sure we can start a second upload + Thread.Sleep(1000); + return true; + } + + if (action == DeterministicErrorBackend.BackendAction.PutBefore) + secondUploadStarted = true; + if (action == DeterministicErrorBackend.BackendAction.PutAfter) + secondUploadCompleted = true; + + return false; + }; + + using (var c = new Controller(failtarget, testopts, null)) + Assert.Throws(() => c.Backup(new string[] { DATAFOLDER })); + + Assert.That(secondUploadStarted, Is.True, "Second upload was not started"); + Assert.That(secondUploadCompleted, Is.True, "Second upload was not started"); + + // Create a regular backup + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + TestUtils.AssertResults(c.Backup(new string[] { DATAFOLDER })); + + // Verify that all is in order + using (var c = new Controller("file://" + TARGETFOLDER, testopts.Expand(new { full_remote_verification = true }), null)) + TestUtils.AssertResults(c.Test(long.MaxValue)); + + // Test that we can recreate + var recreatedDatabaseFile = Path.Combine(BASEFOLDER, "recreated-database.sqlite"); + if (File.Exists(recreatedDatabaseFile)) + File.Delete(recreatedDatabaseFile); + + testopts["dbpath"] = recreatedDatabaseFile; + + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + TestUtils.AssertResults(c.Repair()); + + // Check that we have 3 versions + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + { + var listResults = c.List(); + TestUtils.AssertResults(listResults); + Assert.AreEqual(3, listResults.Filesets.Count()); + } + } + + [Test] + [Category("Disruption")] + [TestCase(true, false)] + [TestCase(false, false)] + [TestCase(true, true)] + [TestCase(false, true)] + public void TestUploadExceptionOnFirstDlistWithRepair(bool before, bool failOnLastDblock) + { + var testopts = TestOptions; + testopts["number-of-retries"] = "0"; + testopts["dblock-size"] = "10mb"; + + // Deterministic error backend + Library.DynamicLoader.BackendLoader.AddBackend(new DeterministicErrorBackend()); + var failtarget = new DeterministicErrorBackend().ProtocolKey + "://" + TARGETFOLDER; + var operation = before + ? DeterministicErrorBackend.BackendAction.PutBefore + : DeterministicErrorBackend.BackendAction.PutAfter; + + // We expect to upload 7 volumes, and fail on the last one + var count = 7; + var failed = false; + + // Fail when uploading the dlist file + DeterministicErrorBackend.ErrorGenerator = (DeterministicErrorBackend.BackendAction action, string remotename) => + { + if (failOnLastDblock) + { + if (action == operation && remotename.Contains(".dblock.")) + { + if (Interlocked.Decrement(ref count) == 0) + { + // Allow others to progress + Thread.Sleep(2000); + failed = true; + return true; + } + } + } + else + { + if (action == operation && remotename.Contains(".dlist.")) + { + failed = true; + return true; + } + } + + return false; + }; + + using (var c = new Controller(failtarget, testopts, null)) + Assert.Throws(() => c.Backup(new string[] { DATAFOLDER })); + + Assert.That(failed, Is.True, "Failed to fail the upload"); + + // Make spacing for the next backup + Thread.Sleep(3000); + + // Issue repair + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + TestUtils.AssertResults(c.Repair()); + + // Verify that all is in order + using (var c = new Controller("file://" + TARGETFOLDER, testopts.Expand(new { full_remote_verification = true }), null)) + TestUtils.AssertResults(c.Test(long.MaxValue)); + + // Make a new backup, should continue as normal + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + { + var backupResults = c.Backup(new string[] { DATAFOLDER }); + TestUtils.AssertResults(backupResults); + Assert.AreEqual(2, c.List().Filesets.Count()); + } + + // Test that we can recreate + var recreatedDatabaseFile = Path.Combine(BASEFOLDER, "recreated-database.sqlite"); + if (File.Exists(recreatedDatabaseFile)) + File.Delete(recreatedDatabaseFile); + + testopts["dbpath"] = recreatedDatabaseFile; + + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + TestUtils.AssertResults(c.Repair()); + + // Check that we have 2 versions + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + { + var listResults = c.List(); + TestUtils.AssertResults(listResults); + Assert.AreEqual(2, listResults.Filesets.Count()); + } + } + + [Test] + [Category("Disruption")] + [TestCase(true, false)] + [TestCase(false, false)] + [TestCase(true, true)] + [TestCase(false, true)] + public void TestUploadExceptionOnFirstDlist(bool before, bool failOnLastDblock) + { + var testopts = TestOptions; + testopts["number-of-retries"] = "0"; + testopts["dblock-size"] = "10mb"; + + // Deterministic error backend + Library.DynamicLoader.BackendLoader.AddBackend(new DeterministicErrorBackend()); + var failtarget = new DeterministicErrorBackend().ProtocolKey + "://" + TARGETFOLDER; + var operation = before + ? DeterministicErrorBackend.BackendAction.PutBefore + : DeterministicErrorBackend.BackendAction.PutAfter; + + // We expect to upload 7 volumes, and fail on the last one + var count = 7; + var failed = false; + + // Fail when uploading the dlist file + DeterministicErrorBackend.ErrorGenerator = (DeterministicErrorBackend.BackendAction action, string remotename) => + { + if (failOnLastDblock) + { + if (action == operation && remotename.Contains(".dblock.")) + { + if (Interlocked.Decrement(ref count) == 0) + { + // Allow others to progress + Thread.Sleep(2000); + failed = true; + return true; + } + } + } + else + { + if (action == operation && remotename.Contains(".dlist.")) + { + failed = true; + return true; + } + } + + return false; + }; + + using (var c = new Controller(failtarget, testopts, null)) + Assert.Throws(() => c.Backup(new string[] { DATAFOLDER })); + + Assert.That(failed, Is.True, "Failed to fail the upload"); + + // Make spacing for the next backup + Thread.Sleep(3000); + + // Make a new backup, should continue as normal + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + { + IBackupResults backupResults = c.Backup(new string[] { DATAFOLDER }); + TestUtils.AssertResults(backupResults); + Assert.AreEqual(2, c.List().Filesets.Count()); + } + + // Verify that all is in order + using (var c = new Controller("file://" + TARGETFOLDER, testopts.Expand(new { full_remote_verification = true }), null)) + TestUtils.AssertResults(c.Test(long.MaxValue)); + + // Test that we can recreate + var recreatedDatabaseFile = Path.Combine(BASEFOLDER, "recreated-database.sqlite"); + if (File.Exists(recreatedDatabaseFile)) + File.Delete(recreatedDatabaseFile); + + testopts["dbpath"] = recreatedDatabaseFile; + + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + TestUtils.AssertResults(c.Repair()); + + // Check that we have 2 versions + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + { + var listResults = c.List(); + TestUtils.AssertResults(listResults); + Assert.AreEqual(2, listResults.Filesets.Count()); + } + } + + [Test] + [Category("Disruption")] + [TestCase(true)] + [TestCase(false)] + public void TestRepairWithNoDlist(bool before) + { + var testopts = TestOptions; + testopts["number-of-retries"] = "0"; + testopts["dblock-size"] = "10mb"; + + // Deterministic error backend + Library.DynamicLoader.BackendLoader.AddBackend(new DeterministicErrorBackend()); + var failtarget = new DeterministicErrorBackend().ProtocolKey + "://" + TARGETFOLDER; + var operation = before + ? DeterministicErrorBackend.BackendAction.PutBefore + : DeterministicErrorBackend.BackendAction.PutAfter; + + // Fail when uploading the first dlist file + DeterministicErrorBackend.ErrorGenerator = (DeterministicErrorBackend.BackendAction action, string remotename) => + { + if (action == operation && remotename.Contains(".dlist.")) + return true; + + return false; + }; + + using (var c = new Controller(failtarget, testopts, null)) + Assert.Throws(() => c.Backup(new string[] { DATAFOLDER })); + + // Test that we can repair + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + TestUtils.AssertResults(c.Repair()); + + // Check that we have 1 version + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + { + var listResults = c.List(); + TestUtils.AssertResults(listResults); + Assert.AreEqual(1, listResults.Filesets.Count()); + } + } + + + [Test] + [Category("Disruption")] + [TestCase(true, true, 3, false)] + [TestCase(false, false, 3, false)] + [TestCase(true, false, 3, false)] + [TestCase(false, true, 3, false)] + [TestCase(true, true, 3, true)] + [TestCase(false, true, 3, true)] + public void TestMultiExceptionOnDlist(bool before, bool modifyInBetween, int runs, bool withBase) + { + var testopts = TestOptions; + testopts["number-of-retries"] = "0"; + testopts["dblock-size"] = "10mb"; + var expectedFilesets = withBase ? 3 : 2; + + if (withBase) + { + // Make a regular backup + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + TestUtils.AssertResults(c.Backup(new string[] { DATAFOLDER })); + } + + + // Deterministic error backend + Library.DynamicLoader.BackendLoader.AddBackend(new DeterministicErrorBackend()); + var failtarget = new DeterministicErrorBackend().ProtocolKey + "://" + TARGETFOLDER; + var operation = before + ? DeterministicErrorBackend.BackendAction.PutBefore + : DeterministicErrorBackend.BackendAction.PutAfter; + + // Fail when uploading the first dlist file + DeterministicErrorBackend.ErrorGenerator = (DeterministicErrorBackend.BackendAction action, string remotename) => + { + if (action == operation && remotename.Contains(".dlist.")) + return true; + + return false; + }; + + for (var i = 0; i < runs; i++) + { + if (modifyInBetween) + ModifySourceFiles(); + + using (var c = new Controller(failtarget, testopts, null)) + try + { + c.Backup(new string[] { DATAFOLDER }); + } + catch (DeterministicErrorBackend.DeterministicErrorBackendException) + { + } + + // Prevent clashes in timestamps + Thread.Sleep(3000); + } + + if (modifyInBetween) + ModifySourceFiles(); + + // Make a new backup, should continue as normal + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + { + var backupResults = c.Backup(new string[] { DATAFOLDER }); + TestUtils.AssertResults(backupResults); + Assert.AreEqual(expectedFilesets, c.List().Filesets.Count()); + } + + // Verify that all is in order + using (var c = new Controller("file://" + TARGETFOLDER, testopts.Expand(new { full_remote_verification = true }), null)) + TestUtils.AssertResults(c.Test(long.MaxValue)); + + // Test that we can recreate + var recreatedDatabaseFile = Path.Combine(BASEFOLDER, "recreated-database.sqlite"); + if (File.Exists(recreatedDatabaseFile)) + File.Delete(recreatedDatabaseFile); + + testopts["dbpath"] = recreatedDatabaseFile; + + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + TestUtils.AssertResults(c.Repair()); + + // Check that we have 2 versions + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + { + var listResults = c.List(); + TestUtils.AssertResults(listResults); + Assert.AreEqual(expectedFilesets, listResults.Filesets.Count()); + } + } + + private class LogSink : IMessageSink + { + public List StartedFiles { get; } = new List(); + public List CompletedFiles { get; } = new List(); + public void BackendEvent(BackendActionType action, BackendEventType type, string path, long size) + { + if (action == BackendActionType.Put && type == BackendEventType.Started) + { + this.StartedFiles.Add(path); + } + if (action == BackendActionType.Put && type == BackendEventType.Completed) + { + this.CompletedFiles.Add(path); + } + } + + public void SetBackendProgress(IBackendProgress progress) + { + } + + public void SetOperationProgress(IOperationProgress progress) + { + } + + public void WriteMessage(LogEntry entry) + { + } + } + + [Test] + [Category("Disruption")] + public void TestDblockUploadWithOperationCancellation() + { + TestUploadWithOperationCancellation(".dblock."); + } + + + [Test] + [Category("Disruption")] + public void TestDindexUploadWithOperationCancellation() + { + TestUploadWithOperationCancellation(".dindex."); + } + + [Test] + [Category("Disruption")] + public void TestDlistUploadWithOperationCancellation() + { + TestUploadWithOperationCancellation(".dlist."); + } + + private void TestUploadWithOperationCancellation(string filefragment) + { + var testopts = TestOptions; + testopts["number-of-retries"] = "1"; + testopts["dblock-size"] = "10mb"; + + // Make a base backup + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + TestUtils.AssertResults(c.Backup(new string[] { DATAFOLDER })); + + // Make a new backup that fails uploading a dblock file + ModifySourceFiles(); + + // Deterministic error backend + Library.DynamicLoader.BackendLoader.AddBackend(new DeterministicErrorBackend()); + var failtarget = new DeterministicErrorBackend().ProtocolKey + "://" + TARGETFOLDER; + var uploadCount = 0; + var failstep = DeterministicErrorBackend.BackendAction.PutBefore; + + // Fail the upload before + DeterministicErrorBackend.ErrorGenerator = (DeterministicErrorBackend.BackendAction action, string remotename) => + { + if (action == failstep && remotename.Contains(filefragment)) + { + // Fail once with a cancellation + if (Interlocked.Increment(ref uploadCount) == 1) + throw new OperationCanceledException(); + } + + return false; + }; + + using (var c = new Controller(failtarget, testopts, null)) + { + var sink = new LogSink(); + c.AppendSink(sink); + var res = c.Backup(new string[] { DATAFOLDER }); + if (uploadCount == 0) + Assert.Fail("Upload count was not incremented"); + + Assert.AreEqual(2, c.List().Filesets.Count()); + Assert.AreEqual(2, Directory.GetFiles(TARGETFOLDER, "*.dlist.*", SearchOption.TopDirectoryOnly).Count()); + Assert.AreEqual(7 * 2 + 1, sink.CompletedFiles.Count); // 7 completed (dblock + dindex) + 1 dlist + Assert.AreEqual(7 * 2 + 2, sink.StartedFiles.Count); // 1 retry + Assert.AreEqual(sink.CompletedFiles.Count(x => x.Contains(".dblock.")), sink.CompletedFiles.Count(x => x.Contains(".dindex."))); + Assert.AreEqual(1, res.BackendStatistics.RetryAttempts); + } + + uploadCount = 0; + failstep = DeterministicErrorBackend.BackendAction.PutAfter; + + ModifySourceFiles(); + using (var c = new Controller(failtarget, testopts, null)) + { + var sink = new LogSink(); + c.AppendSink(sink); + var res = c.Backup(new string[] { DATAFOLDER }); + if (uploadCount == 0) + Assert.Fail("Upload count was not incremented"); + + Assert.AreEqual(3, c.List().Filesets.Count()); + Assert.AreEqual(3, Directory.GetFiles(TARGETFOLDER, "*.dlist.*", SearchOption.TopDirectoryOnly).Count()); + Assert.AreEqual(7 * 2 + 1, sink.CompletedFiles.Count); // 7 completed (dblock + dindex) + 1 dlist + Assert.AreEqual(7 * 2 + 2, sink.StartedFiles.Count); // 1 retry + Assert.AreEqual(sink.CompletedFiles.Count(x => x.Contains(".dblock.")), sink.CompletedFiles.Count(x => x.Contains(".dindex."))); + Assert.AreEqual(1, res.BackendStatistics.RetryAttempts); + } + + ModifySourceFiles(); + // Create a regular backup + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + TestUtils.AssertResults(c.Backup(new string[] { DATAFOLDER })); + + // Verify that all is in order + using (var c = new Controller("file://" + TARGETFOLDER, testopts.Expand(new { full_remote_verification = true }), null)) + TestUtils.AssertResults(c.Test(long.MaxValue)); + + // Test that we can recreate + var recreatedDatabaseFile = Path.Combine(BASEFOLDER, "recreated-database.sqlite"); + if (File.Exists(recreatedDatabaseFile)) + File.Delete(recreatedDatabaseFile); + + testopts["dbpath"] = recreatedDatabaseFile; + + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + TestUtils.AssertResults(c.Repair()); + + // Check that we have 3 versions + using (var c = new Controller("file://" + TARGETFOLDER, testopts, null)) + { + var listResults = c.List(); + TestUtils.AssertResults(listResults); + Assert.AreEqual(4, listResults.Filesets.Count()); + } + } } } \ No newline at end of file diff --git a/Duplicati/UnitTest/Duplicati.UnitTest.csproj b/Duplicati/UnitTest/Duplicati.UnitTest.csproj index e3623c8a9..9925d51ee 100644 --- a/Duplicati/UnitTest/Duplicati.UnitTest.csproj +++ b/Duplicati/UnitTest/Duplicati.UnitTest.csproj @@ -8,7 +8,7 @@ Duplicati.UnitTest Copyright © 2025 Team Duplicati, MIT license - + @@ -35,14 +35,15 @@ + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Duplicati/UnitTest/IOTests.cs b/Duplicati/UnitTest/IOTests.cs index bc0021511..0be6d3de7 100644 --- a/Duplicati/UnitTest/IOTests.cs +++ b/Duplicati/UnitTest/IOTests.cs @@ -1,30 +1,28 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System.Text; using NUnit.Framework; using Duplicati.Library.Common.IO; -using Duplicati.Library.Utility; -using Duplicati.Library.Common; using System.Collections.Generic; using System; @@ -62,13 +60,13 @@ namespace Duplicati.UnitTest var filePath = root + filename; var filePathWithExtendedDevicePathPrefix = SystemIOWindows.AddExtendedDevicePathPrefix(filePath); - var filePathWithExtendedDevicePathPrefixRoot = SystemIO.IO_WIN.GetPathRoot(filePathWithExtendedDevicePathPrefix); + var filePathWithExtendedDevicePathPrefixRoot = SystemIO.IO_OS.GetPathRoot(filePathWithExtendedDevicePathPrefix); //Prefixed with extended device path prefix remains prefixed Assert.AreEqual(SystemIOWindows.AddExtendedDevicePathPrefix(root), filePathWithExtendedDevicePathPrefixRoot); //Without extended device path prefix, no prefix. - var filePathRoot = SystemIO.IO_WIN.GetPathRoot(filePath); + var filePathRoot = SystemIO.IO_OS.GetPathRoot(filePath); Assert.AreEqual(root, filePathRoot); } @@ -282,7 +280,7 @@ namespace Duplicati.UnitTest }; foreach (var path in testCasesWherePathGetFullGivesSameResultsAsDotNet) { - var actual = SystemIO.IO_WIN.PathGetFullPath(path); + var actual = SystemIO.IO_OS.PathGetFullPath(path); var expected = System.IO.Path.GetFullPath(path); Assert.AreEqual(expected, actual, $"Path: {path}"); } @@ -313,7 +311,7 @@ namespace Duplicati.UnitTest foreach (var keyValuePair in testCasesWherePathGetFullGivesDifferentResultsThanDotNet) { var path = keyValuePair.Key; - var actual = SystemIO.IO_WIN.PathGetFullPath(path); + var actual = SystemIO.IO_OS.PathGetFullPath(path); var expected = keyValuePair.Value; Assert.AreEqual(expected, actual, $"Path: {path}"); } diff --git a/Duplicati/UnitTest/ImportExportTests.cs b/Duplicati/UnitTest/ImportExportTests.cs index 9663d5ec1..26062445c 100644 --- a/Duplicati/UnitTest/ImportExportTests.cs +++ b/Duplicati/UnitTest/ImportExportTests.cs @@ -1,28 +1,29 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; using System.IO; using Duplicati.Library.RestAPI; +using Duplicati.Library.SQLiteHelper; using Duplicati.Server; using Duplicati.Server.Database; using Duplicati.Server.Serializable; @@ -126,46 +127,57 @@ namespace Duplicati.UnitTest [Category("ImportExport")] public void RoundTrip() { - Dictionary metadata = new Dictionary { { "SourceFilesCount", "1" } }; - Dictionary advancedOptions = new Dictionary { { "server-datafolder", this.serverDatafolder } }; + var metadata = new Dictionary { { "SourceFilesCount", "1" } }; + var advancedOptions = new Dictionary { { "server-datafolder", this.serverDatafolder } }; + + // Mock the setup enough to get the import/export working var serviceCollection = new ServiceCollection(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(new EventPollNotify()); FIXMEGlobal.Provider = new DefaultServiceProviderFactory().CreateServiceProvider(serviceCollection); + FIXMEGlobal.DataFolder = this.serverDatafolder; - using (Program.DataConnection = Program.GetDatabaseConnection(advancedOptions, true)) + var dbpath = Path.Combine(this.serverDatafolder, Library.AutoUpdater.DataFolderManager.SERVER_DATABASE_FILENAME); + if (File.Exists(dbpath)) + File.Delete(dbpath); + + var con = SQLiteLoader.LoadConnection(); + SQLiteLoader.OpenDatabase(con, dbpath, null); + DatabaseUpgrader.UpgradeDatabase(con, dbpath, typeof(Duplicati.Library.RestAPI.Database.DatabaseConnectionSchemaMarker)); + + using (var connection = new Connection(con, true, null)) { // Unencrypted file, don't import metadata. string unencryptedWithoutMetadata = Path.Combine(this.serverDatafolder, Path.GetRandomFileName()); - File.WriteAllBytes(unencryptedWithoutMetadata, BackupImportExportHandler.ExportToJSON(Program.DataConnection, this.CreateBackup("unencrypted without metadata", "user", "password", metadata), null)); - BackupImportExportHandler.ImportBackup(Program.DataConnection, unencryptedWithoutMetadata, false, () => null); - Assert.AreEqual(1, Program.DataConnection.Backups.Length); - Assert.AreEqual(0, Program.DataConnection.Backups[0].Metadata.Count); + File.WriteAllBytes(unencryptedWithoutMetadata, BackupImportExportHandler.ExportToJSON(connection, this.CreateBackup("unencrypted without metadata", "user", "password", metadata), null)); + BackupImportExportHandler.ImportBackup(connection, unencryptedWithoutMetadata, false, () => null); + Assert.AreEqual(1, connection.Backups.Length); + Assert.AreEqual(0, connection.Backups[0].Metadata.Count); // Unencrypted file, import metadata. string unencryptedWithMetadata = Path.Combine(this.serverDatafolder, Path.GetRandomFileName()); - File.WriteAllBytes(unencryptedWithMetadata, BackupImportExportHandler.ExportToJSON(Program.DataConnection, this.CreateBackup("unencrypted with metadata", "user", "password", metadata), null)); - BackupImportExportHandler.ImportBackup(Program.DataConnection, unencryptedWithMetadata, true, () => null); - Assert.AreEqual(2, Program.DataConnection.Backups.Length); - Assert.AreEqual(metadata.Count, Program.DataConnection.Backups[1].Metadata.Count); + File.WriteAllBytes(unencryptedWithMetadata, BackupImportExportHandler.ExportToJSON(connection, this.CreateBackup("unencrypted with metadata", "user", "password", metadata), null)); + BackupImportExportHandler.ImportBackup(connection, unencryptedWithMetadata, true, () => null); + Assert.AreEqual(2, connection.Backups.Length); + Assert.AreEqual(metadata.Count, connection.Backups[1].Metadata.Count); // Encrypted file, don't import metadata. string encryptedWithoutMetadata = Path.Combine(this.serverDatafolder, Path.GetRandomFileName()); string passphrase = "abcde"; - File.WriteAllBytes(encryptedWithoutMetadata, BackupImportExportHandler.ExportToJSON(Program.DataConnection, this.CreateBackup("encrypted without metadata", "user", "password", metadata), passphrase)); - BackupImportExportHandler.ImportBackup(Program.DataConnection, encryptedWithoutMetadata, false, () => passphrase); - Assert.AreEqual(3, Program.DataConnection.Backups.Length); - Assert.AreEqual(0, Program.DataConnection.Backups[2].Metadata.Count); + File.WriteAllBytes(encryptedWithoutMetadata, BackupImportExportHandler.ExportToJSON(connection, this.CreateBackup("encrypted without metadata", "user", "password", metadata), passphrase)); + BackupImportExportHandler.ImportBackup(connection, encryptedWithoutMetadata, false, () => passphrase); + Assert.AreEqual(3, connection.Backups.Length); + Assert.AreEqual(0, connection.Backups[2].Metadata.Count); // Encrypted file, import metadata. string encryptedWithMetadata = Path.Combine(this.serverDatafolder, Path.GetRandomFileName()); - File.WriteAllBytes(encryptedWithMetadata, BackupImportExportHandler.ExportToJSON(Program.DataConnection, this.CreateBackup("encrypted with metadata", "user", "password", metadata), passphrase)); - BackupImportExportHandler.ImportBackup(Program.DataConnection, encryptedWithMetadata, true, () => passphrase); - Assert.AreEqual(4, Program.DataConnection.Backups.Length); - Assert.AreEqual(metadata.Count, Program.DataConnection.Backups[3].Metadata.Count); + File.WriteAllBytes(encryptedWithMetadata, BackupImportExportHandler.ExportToJSON(connection, this.CreateBackup("encrypted with metadata", "user", "password", metadata), passphrase)); + BackupImportExportHandler.ImportBackup(connection, encryptedWithMetadata, true, () => passphrase); + Assert.AreEqual(4, connection.Backups.Length); + Assert.AreEqual(metadata.Count, connection.Backups[3].Metadata.Count); // Encrypted file, incorrect passphrase. - Assert.Throws(Is.InstanceOf(), () => BackupImportExportHandler.ImportBackup(Program.DataConnection, encryptedWithMetadata, true, () => passphrase + " ")); + Assert.Throws(Is.InstanceOf(), () => BackupImportExportHandler.ImportBackup(connection, encryptedWithMetadata, true, () => passphrase + " ")); } } } diff --git a/Duplicati/UnitTest/Issue4312.cs b/Duplicati/UnitTest/Issue4312.cs new file mode 100644 index 000000000..cbfa60e63 --- /dev/null +++ b/Duplicati/UnitTest/Issue4312.cs @@ -0,0 +1,137 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using NUnit.Framework; + +namespace Duplicati.UnitTest; + +public class Issue4312 : BasicSetupHelper +{ + [Test] + [Category("Targeted")] + public void ChangeTimestampShouldCreateExtraBackup() + { + var testopts = TestOptions; + // Make sure we detect changes from metadata + testopts["upload-unchanged-backups"] = "false"; + testopts["blocksize"] = "50kb"; + + var data = new byte[1024 * 1024 * 10]; + File.WriteAllBytes(Path.Combine(DATAFOLDER, "a"), data); + using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null)) + { + var r = c.Backup(new string[] { DATAFOLDER }); + Assert.AreEqual(0, r.Errors.Count()); + Assert.AreEqual(0, r.Warnings.Count()); + Assert.AreEqual(1, r.AddedFiles); + var pr = (Library.Interface.IParsedBackendStatistics)r.BackendStatistics; + if (pr.KnownFileSize == 0 || pr.KnownFileCount != 3 || pr.BackupListCount != 1) + throw new Exception(string.Format("Failed to get stats from remote backend: {0}, {1}, {2}", pr.KnownFileSize, pr.KnownFileCount, pr.BackupListCount)); + + System.Threading.Thread.Sleep(3000); + + r = c.Backup(new string[] { DATAFOLDER }); + Assert.AreEqual(0, r.Errors.Count()); + Assert.AreEqual(0, r.Warnings.Count()); + Assert.AreEqual(0, r.AddedFiles); + Assert.AreEqual(0, r.ModifiedFiles); + pr = (Library.Interface.IParsedBackendStatistics)r.BackendStatistics; + if (pr.KnownFileSize == 0 || pr.KnownFileCount != 3 || pr.BackupListCount != 1) + throw new Exception(string.Format("Failed to get stats from remote backend: {0}, {1}, {2}", pr.KnownFileSize, pr.KnownFileCount, pr.BackupListCount)); + + System.Threading.Thread.Sleep(3000); + + // Force a change in the file metadata + File.SetLastWriteTimeUtc(Path.Combine(DATAFOLDER, "a"), DateTime.Now); + + r = c.Backup(new string[] { DATAFOLDER }); + Assert.AreEqual(0, r.Errors.Count()); + Assert.AreEqual(0, r.Warnings.Count()); + Assert.AreEqual(0, r.AddedFiles); + Assert.AreEqual(1, r.ModifiedFiles); + pr = (Library.Interface.IParsedBackendStatistics)r.BackendStatistics; + if (pr.KnownFileSize == 0 || pr.KnownFileCount != 6 || pr.BackupListCount != 2) + throw new Exception(string.Format("Failed to get stats from remote backend: {0}, {1}, {2}", pr.KnownFileSize, pr.KnownFileCount, pr.BackupListCount)); + } + } + + [Test] + [Category("Targeted")] + public void BackupFromRecreatedDatabaseShouldUpdateMetadata() + { + var testopts = TestOptions; + // Make sure we detect changes from metadata + testopts["upload-unchanged-backups"] = "false"; + testopts["blocksize"] = "50kb"; + + var data = new byte[1024 * 1024 * 10]; + File.WriteAllBytes(Path.Combine(DATAFOLDER, "a"), data); + using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null)) + { + var r = c.Backup(new string[] { DATAFOLDER }); + Assert.AreEqual(0, r.Errors.Count()); + Assert.AreEqual(0, r.Warnings.Count()); + Assert.AreEqual(1, r.AddedFiles); + var pr = (Library.Interface.IParsedBackendStatistics)r.BackendStatistics; + if (pr.KnownFileSize == 0 || pr.KnownFileCount != 3 || pr.BackupListCount != 1) + throw new Exception(string.Format("Failed to get stats from remote backend: {0}, {1}, {2}", pr.KnownFileSize, pr.KnownFileCount, pr.BackupListCount)); + + // Remove the database + File.Delete(testopts["dbpath"]); + + // Get a second between the two backups + Thread.Sleep(2000); + + // Recreate + var rr = c.Repair(); + Assert.AreEqual(0, rr.Errors.Count()); + Assert.AreEqual(0, rr.Warnings.Count()); + + // Because the timestamps are restored with lower precision + // this will trigger a rescan of the files + r = c.Backup(new string[] { DATAFOLDER }); + Assert.AreEqual(0, r.Errors.Count()); + Assert.AreEqual(0, r.Warnings.Count()); + Assert.AreEqual(0, r.AddedFiles); + Assert.AreEqual(0, r.ModifiedFiles); + Assert.AreEqual(1, r.OpenedFiles); + pr = (Library.Interface.IParsedBackendStatistics)r.BackendStatistics; + if (pr.KnownFileSize == 0 || pr.KnownFileCount != 3 || pr.BackupListCount != 1) + throw new Exception(string.Format("Looks like the metadata scan failed: {0}, {1}, {2}", pr.KnownFileSize, pr.KnownFileCount, pr.BackupListCount)); + + // Make a backup again, and ensure that no files are opened + r = c.Backup(new string[] { DATAFOLDER }); + Assert.AreEqual(0, r.Errors.Count()); + Assert.AreEqual(0, r.Warnings.Count()); + Assert.AreEqual(0, r.AddedFiles); + Assert.AreEqual(0, r.ModifiedFiles); + Assert.AreEqual(0, r.OpenedFiles); + pr = (Library.Interface.IParsedBackendStatistics)r.BackendStatistics; + if (pr.KnownFileSize == 0 || pr.KnownFileCount != 3 || pr.BackupListCount != 1) + throw new Exception(string.Format("Failed to get stats from remote backend: {0}, {1}, {2}", pr.KnownFileSize, pr.KnownFileCount, pr.BackupListCount)); + } + + } +} diff --git a/Duplicati/UnitTest/Issue5845.cs b/Duplicati/UnitTest/Issue5845.cs index 661228aaa..9eef94cd9 100644 --- a/Duplicati/UnitTest/Issue5845.cs +++ b/Duplicati/UnitTest/Issue5845.cs @@ -68,8 +68,8 @@ namespace Duplicati.UnitTest => backend.GetAsync(remotename, filename, cancellationToken); public Task GetDNSNamesAsync(CancellationToken cancelToken) => GetDNSNamesAsync(cancelToken); - public IEnumerable List() - => Sorter(backend.List()); + public IAsyncEnumerable ListAsync(CancellationToken cancellationToken) + => Sorter(backend.ListAsync(cancellationToken).ToBlockingEnumerable()).ToAsyncEnumerable(); public Task PutAsync(string remotename, Stream stream, CancellationToken cancelToken) => backend.PutAsync(remotename, stream, cancelToken); diff --git a/Duplicati/UnitTest/IssueTests.cs b/Duplicati/UnitTest/IssueTests.cs index bd706946a..33ffebdf6 100644 --- a/Duplicati/UnitTest/IssueTests.cs +++ b/Duplicati/UnitTest/IssueTests.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team +// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the +// +// 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 +// +// 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 +// +// 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 Duplicati.Library.DynamicLoader; using Duplicati.Library.Interface; @@ -68,10 +68,10 @@ namespace Duplicati.UnitTest // Fail after index file put target = new DeterministicErrorBackend().ProtocolKey + "://" + TARGETFOLDER; string interruptedName = ""; - DeterministicErrorBackend.ErrorGenerator = (string action, string remotename) => + DeterministicErrorBackend.ErrorGenerator = (DeterministicErrorBackend.BackendAction action, string remotename) => { // Fail dindex upload - if (action.StartsWith("put") && remotename.Contains("dindex")) + if (action == DeterministicErrorBackend.BackendAction.PutAfter && remotename.Contains("dindex")) { interruptedName = remotename; return true; @@ -113,11 +113,14 @@ namespace Duplicati.UnitTest if (compactBeforeRecreate) { + // Make room for a backup + Thread.Sleep(2000); + // Delete f2 and compact File.Delete(file2); - using (var c = new Library.Main.Controller(target, testopts, null)) + using (var c = new Controller(target, testopts, null)) { - IBackupResults backupResults = c.Backup(new[] { DATAFOLDER }); + var backupResults = c.Backup(new[] { DATAFOLDER }); TestUtils.AssertResults(backupResults); ICompactResults compactResults = c.Compact(); TestUtils.AssertResults(compactResults); @@ -311,6 +314,7 @@ namespace Duplicati.UnitTest [Test] + [Category("Restore"), Category("Bug")] public void Issue5886RestoreModifiedMiddleBlock() { var blocksize = 1024; @@ -374,6 +378,68 @@ namespace Duplicati.UnitTest } } + [Test] + [Category("Restore"), Category("Bug")] + public void Issue5957RestoreModifiedBlockUseLocalBlocks([Values] bool use_local, [Values] bool overwrite) + { + var blocksize = 1024; + var testopts = new Dictionary(TestOptions) + { + ["blocksize"] = $"{blocksize}b", + ["restore-with-local-blocks"] = use_local.ToString().ToLower(), + ["overwrite"] = overwrite.ToString().ToLower() + }; + + var original_dir = Path.Combine(DATAFOLDER, "some_original_dir"); + Directory.CreateDirectory(original_dir); + string f = Path.Combine(original_dir, "some_file"); + TestUtils.WriteTestFile(f, 3 * blocksize); + + // Backup the files + using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null)) + { + IBackupResults backupResults = c.Backup([DATAFOLDER]); + TestUtils.AssertResults(backupResults); + } + + var original_contents = File.ReadAllBytes(f); + + using (var stream = File.Open(f, FileMode.Open, FileAccess.ReadWrite)) + { + stream.Seek(0, SeekOrigin.Begin); + byte[] buffer = new byte[1]; + stream.Read(buffer, 0, 1); + buffer[0] = (byte)~buffer[0]; + stream.Seek(0, SeekOrigin.Begin); + stream.Write(buffer, 0, 1); + } + + var restored_contents = File.ReadAllBytes(f); + Assert.That(restored_contents, Is.Not.EqualTo(original_contents), "Restored file should not be equal to original file"); + + // Attempt to restore the file + using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null)) + { + var restoreResults = c.Restore([f]); + Assert.That(restoreResults.RestoredFiles, Is.EqualTo(1), "File should have been restored."); + } + + // Verify that the files are equal + // List files in folder + if (overwrite) + { + restored_contents = File.ReadAllBytes(f); + } + else + { + var files = Directory.GetFiles(original_dir, "*", SearchOption.TopDirectoryOnly); + Assert.That(files.Length, Is.EqualTo(2), "There should be two files in the folder"); + var f2 = files.FirstOrDefault(v => v != f); + restored_contents = File.ReadAllBytes(f2); + } + Assert.That(restored_contents, Is.EqualTo(original_contents), "Restored file should be equal to original file"); + } + [Test] [Category("Disruption"), Category("Bug")] @@ -397,7 +463,7 @@ namespace Duplicati.UnitTest bool failed = false; long accessCounter = 0; long errorIdx = 0; - DeterministicErrorBackend.ErrorGenerator = (string action, string remotename) => + DeterministicErrorBackend.ErrorGenerator = (DeterministicErrorBackend.BackendAction action, string remotename) => { ++accessCounter; if (accessCounter >= errorIdx) diff --git a/Duplicati/UnitTest/RandomErrorBackend.cs b/Duplicati/UnitTest/RandomErrorBackend.cs index d295ff3f1..e520c937b 100644 --- a/Duplicati/UnitTest/RandomErrorBackend.cs +++ b/Duplicati/UnitTest/RandomErrorBackend.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Interface; @@ -72,9 +72,9 @@ namespace Duplicati.UnitTest #endregion #region IBackend implementation - public IEnumerable List() + public IAsyncEnumerable ListAsync(CancellationToken cancellationToken) { - return m_backend.List(); + return m_backend.ListAsync(cancellationToken); } public async Task PutAsync(string remotename, string filename, CancellationToken cancelToken) { diff --git a/Duplicati/UnitTest/RepairHandlerTests.cs b/Duplicati/UnitTest/RepairHandlerTests.cs index 790d459d4..308596c2c 100644 --- a/Duplicati/UnitTest/RepairHandlerTests.cs +++ b/Duplicati/UnitTest/RepairHandlerTests.cs @@ -24,6 +24,7 @@ using System.Data; using System.IO; using System.IO.Compression; using System.Linq; +using System.Threading; using Duplicati.Library.Interface; using Duplicati.Library.Main; using Duplicati.Library.Main.Database; @@ -43,6 +44,11 @@ namespace Duplicati.UnitTest File.WriteAllBytes(Path.Combine(this.DATAFOLDER, "file"), new byte[] { 0 }); } + public void ModifyFile() + { + File.WriteAllBytes(Path.Combine(this.DATAFOLDER, "file"), new byte[] { 1 }); + } + [Test] [Category("RepairHandler")] public void RepairMissingBlocklistHashes() @@ -429,5 +435,132 @@ namespace Duplicati.UnitTest } } } + + [Test] + [Category("RepairHandler")] + public void RepairMissingDlistFile() + { + // Make two backups + var options = this.TestOptions; + using (var c = new Controller("file://" + this.TARGETFOLDER, options, null)) + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); + + ModifyFile(); + using (var c = new Controller("file://" + this.TARGETFOLDER, options, null)) + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); + + // Find and delete a dlist file + var dlistFile = Directory.EnumerateFiles(this.TARGETFOLDER, "*.dlist.*").First(); + File.Delete(dlistFile); + + using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) + { + IRepairResults repairResults = c.Repair(); + TestUtils.AssertResults(repairResults); + Assert.AreEqual(2, Directory.EnumerateFiles(this.TARGETFOLDER, "*.dlist.*").Count()); + } + } + + [Test] + [Category("RepairHandler")] + [TestCase("true")] + [TestCase("false")] + public void RepairMissingDlistVolume(bool deleteRemoteFile) + { + // Make two backups + var options = this.TestOptions; + using (var c = new Controller("file://" + this.TARGETFOLDER, options, null)) + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); + + ModifyFile(); + using (var c = new Controller("file://" + this.TARGETFOLDER, options, null)) + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); + + // Find a dlist file + var dlistFile = Directory.EnumerateFiles(this.TARGETFOLDER, "*.dlist.*").First(); + if (deleteRemoteFile) + File.Delete(dlistFile); + using (var con = SQLiteLoader.LoadConnection(options["dbpath"])) + using (var cmd = con.CreateCommand()) + Assert.AreEqual(1, cmd.ExecuteNonQuery("DELETE FROM RemoteVolume WHERE Name = ?", Path.GetFileName(dlistFile))); + + // Should catch this in validation + using (var c = new Controller("file://" + this.TARGETFOLDER, options, null)) + Assert.Throws(() => c.Backup(new[] { this.DATAFOLDER })); + + using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) + { + IRepairResults repairResults = c.Repair(); + TestUtils.AssertResults(repairResults); + Assert.AreEqual(2, Directory.EnumerateFiles(this.TARGETFOLDER, "*.dlist.*").Count()); + Assert.AreEqual(2, c.List().Filesets.Count()); + } + + // Check that the entry was recreated + using (var con = SQLiteLoader.LoadConnection(options["dbpath"])) + using (var cmd = con.CreateCommand()) + Assert.AreEqual(2, cmd.ExecuteScalarInt64("SELECT COUNT(*) FROM RemoteVolume WHERE Name LIKE '%.dlist.%' AND State != ?", -1, RemoteVolumeState.Deleted.ToString())); + + // Delete the database and check that the result is correct + File.Delete(options["dbpath"]); + + using (Controller c = new Controller("file://" + this.TARGETFOLDER, options, null)) + { + IRepairResults repairResults = c.Repair(); + TestUtils.AssertResults(repairResults); + Assert.AreEqual(2, c.List().Filesets.Count()); + + var r = c.Test(long.MaxValue); + Assert.AreEqual(0, r.Errors.Count()); + Assert.AreEqual(0, r.Warnings.Count()); + Assert.IsFalse(r.Verifications.Any(p => p.Value.Any())); + } + } + + [Test] + [Category("RepairHandler")] + public void RepairMissingFilesetVolume() + { + // Make two backups + var options = this.TestOptions; + using (var c = new Controller("file://" + this.TARGETFOLDER, options, null)) + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); + + // Make room for a new backup + Thread.Sleep(2000); + + ModifyFile(); + using (var c = new Controller("file://" + this.TARGETFOLDER, options, null)) + TestUtils.AssertResults(c.Backup(new[] { this.DATAFOLDER })); + + // Remove a fileset + using (var con = SQLiteLoader.LoadConnection(options["dbpath"])) + using (var cmd = con.CreateCommand()) + { + var filesetId = cmd.ExecuteScalarInt64("SELECT Id FROM Fileset ORDER BY Id DESC LIMIT 1"); + Assert.AreEqual(1, cmd.ExecuteNonQuery("DELETE FROM Fileset WHERE Id = ?", filesetId)); + cmd.ExecuteNonQuery("DELETE FROM FilesetEntry WHERE FilesetId = ?", filesetId); + } + + // Should catch this in validation + using (var c = new Controller("file://" + this.TARGETFOLDER, options, null)) + Assert.Throws(() => c.Backup(new[] { this.DATAFOLDER })); + + // Since we have removed the entry that is being checked here, we need to disable the check + var repairOptions = new Dictionary(options) { ["repair-ignore-outdated-database"] = "true" }; + using (Controller c = new Controller("file://" + this.TARGETFOLDER, repairOptions, null)) + { + IRepairResults repairResults = c.Repair(); + Assert.AreEqual(0, repairResults.Errors.Count()); + Assert.AreEqual(0, repairResults.Warnings.Where(x => x.IndexOf("RemoteFilesNewerThanLocalDatabase", StringComparison.OrdinalIgnoreCase) < 0).Count()); + Assert.AreEqual(2, Directory.EnumerateFiles(this.TARGETFOLDER, "*.dlist.*").Count()); + Assert.AreEqual(2, c.List().Filesets.Count()); + } + + // Check that entry was recreated + using (var con = SQLiteLoader.LoadConnection(options["dbpath"])) + using (var cmd = con.CreateCommand()) + Assert.AreEqual(2, cmd.ExecuteScalarInt64("SELECT COUNT(*) FROM Fileset")); + } } } \ No newline at end of file diff --git a/Duplicati/UnitTest/SVNCheckoutsTest.cs b/Duplicati/UnitTest/SVNCheckoutsTest.cs index 22a74dccf..44a8a589d 100644 --- a/Duplicati/UnitTest/SVNCheckoutsTest.cs +++ b/Duplicati/UnitTest/SVNCheckoutsTest.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. @@ -27,6 +27,7 @@ using Duplicati.Library.Logging; using Duplicati.Library.Utility; using System.Linq; using Duplicati.Library.Common.IO; +using System.Threading.Tasks; namespace Duplicati.UnitTest { @@ -173,7 +174,7 @@ namespace Duplicati.UnitTest try { using (var bk = Duplicati.Library.DynamicLoader.BackendLoader.GetBackend(target, options)) - foreach (var f in bk.List()) + foreach (var f in bk.ListAsync(System.Threading.CancellationToken.None).ToBlockingEnumerable()) if (!f.IsFolder) Utility.Await(bk.DeleteAsync(f.Name, System.Threading.CancellationToken.None)); } @@ -215,7 +216,7 @@ namespace Duplicati.UnitTest Duplicati.Library.Main.Options opts = new Duplicati.Library.Main.Options(options); using (Duplicati.Library.Interface.IBackend bk = Duplicati.Library.DynamicLoader.BackendLoader.GetBackend(target, options)) - foreach (Duplicati.Library.Interface.IFileEntry fe in bk.List()) + foreach (Duplicati.Library.Interface.IFileEntry fe in bk.ListAsync(System.Threading.CancellationToken.None).ToBlockingEnumerable()) if (fe.Size > opts.VolumeSize) { string msg = string.Format("The file {0} is {1} bytes larger than allowed", fe.Name, fe.Size - opts.VolumeSize); @@ -406,6 +407,13 @@ namespace Duplicati.UnitTest restoredname = System.IO.Path.Combine(System.IO.Path.Combine(tempfolder, System.IO.Path.GetFileName(rootfolder.Split(System.IO.Path.PathSeparator)[int.Parse(s.Substring(0, six))])), s.Substring(six + 1)); } + if (!System.IO.File.Exists(sourcename)) + { + Log.WriteErrorMessage(LOGTAG, "PartialRestoreMissingFile", null, "Partial restore, missing SOURCE file: {0}", sourcename); + BasicSetupHelper.ProgressWriteLine("Partial restore missing file: " + sourcename); + throw new Exception("Unittest is broken"); + } + if (!System.IO.File.Exists(restoredname)) { Log.WriteErrorMessage(LOGTAG, "PartialRestoreMissingFile", null, "Partial restore missing file: {0}", restoredname); @@ -413,13 +421,6 @@ namespace Duplicati.UnitTest } else { - if (!System.IO.File.Exists(sourcename)) - { - Log.WriteErrorMessage(LOGTAG, "PartialRestoreMissingFile", null, "Partial restore missing file: {0}", sourcename); - BasicSetupHelper.ProgressWriteLine("Partial restore missing file: " + sourcename); - throw new Exception("Unittest is broken"); - } - TestUtils.AssertFilesAreEqual(sourcename, restoredname, verifymetadata, $"Partial restore file differs: {s}"); } } diff --git a/Duplicati/UnitTest/SizeOmittingBackend.cs b/Duplicati/UnitTest/SizeOmittingBackend.cs index 74efe540b..a1a464a91 100644 --- a/Duplicati/UnitTest/SizeOmittingBackend.cs +++ b/Duplicati/UnitTest/SizeOmittingBackend.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Common.IO; @@ -59,10 +59,10 @@ namespace Duplicati.UnitTest #endregion #region IBackend implementation - public IEnumerable List() + public IAsyncEnumerable ListAsync(CancellationToken cancellationToken) { return - from n in m_backend.List() + from n in m_backend.ListAsync(cancellationToken) where !n.IsFolder select new FileEntry(n.Name); } diff --git a/Duplicati/UnitTest/TestUtils.cs b/Duplicati/UnitTest/TestUtils.cs index d8b4a37e8..b5e1e5e98 100644 --- a/Duplicati/UnitTest/TestUtils.cs +++ b/Duplicati/UnitTest/TestUtils.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -24,25 +24,17 @@ using System.IO; using System.Collections.Generic; using Duplicati.Library.Utility; using System.Linq; -using Duplicati.Library.Logging; using System.Reflection; using System.Threading; using System.Threading.Tasks; using Duplicati.Library.Common.IO; using NUnit.Framework; -using System.Runtime.InteropServices; -using Duplicati.Library.Common; using Duplicati.Library.Interface; namespace Duplicati.UnitTest { public static class TestUtils { - /// - /// The log tag - /// - private static readonly string LOGTAG = Library.Logging.Log.LogTagFromType(typeof(TestUtils)); - public static Dictionary DefaultOptions { get @@ -161,22 +153,6 @@ namespace Duplicati.UnitTest Console.WriteLine("Encountered additional {0} timestamp errors!", timestampfailures); } - /// - /// Returns the index of a given string, using the file system case sensitivity - /// - /// The index of the entry or -1 if no entry was found - /// The list to search - /// The string to find - private static int IndexOf(List lst, string m) - { - StringComparison sc = Duplicati.Library.Utility.Utility.ClientFilenameStringComparison; - for (int i = 0; i < lst.Count; i++) - if (lst[i].Equals(m, sc)) - return i; - - return -1; - } - /// /// Asserts that the two directory trees are equivalent; i.e., /// that they they contain the same directories and files, recursively. @@ -315,6 +291,9 @@ namespace Duplicati.UnitTest } Assert.AreEqual(0, results.Errors.Count(), "{0} errors:\n{1}", operation, string.Join("\n", results.Errors)); Assert.AreEqual(0, results.Warnings.Count(), "{0} warnings:\n{1}", operation, string.Join("\n", results.Warnings)); + + if (results is ITestResults testResults) + Assert.IsFalse(testResults.Verifications.Any(p => p.Value.Any()), "{0} verifications:\n{1}", operation, string.Join("\n", testResults.Verifications.Select(p => string.Join("\n", p.Value)))); } } } diff --git a/Duplicati/UnitTest/ToolTests.cs b/Duplicati/UnitTest/ToolTests.cs new file mode 100644 index 000000000..e5ec55dad --- /dev/null +++ b/Duplicati/UnitTest/ToolTests.cs @@ -0,0 +1,529 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using Duplicati.Library.Main; +using NUnit.Framework; +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace Duplicati.UnitTest +{ + + /// + /// Tests the tools. + /// + public class ToolTests : BasicSetupHelper + { + + /// + /// Tests that the remote synchronization tool doesn't do anything when the dry run option is used. + /// + [Test] + [Category("Tools")] + public void TestDryRun() + { + var l1 = Path.Combine(TARGETFOLDER, "l1"); + var l2 = Path.Combine(TARGETFOLDER, "l2"); + + Directory.CreateDirectory(l1); + Directory.CreateDirectory(l2); + + GenerateTestData(l1, 5, 0, 0, 1024).Wait(); + + var args = new string[] { $"file://{l1}", $"file://{l2}", "--confirm", "--dry-run" }; + + var async_call = RemoteSynchronization.Program.Main(args); + var return_code = async_call.ConfigureAwait(false).GetAwaiter().GetResult(); + + Assert.AreEqual(0, return_code, "Remote synchronization tool did not return 0."); + Assert.IsFalse(DirectoriesAndContentsAreEqual(l1, l2), "Synchronized directories are equal"); + Assert.IsTrue(!Directory.EnumerateFiles(l2).Any(), "Destination directory is not empty"); + } + + /// + /// Tests that the remote synchronization tool works with an empty source to an empty destination. + /// + [Test] + [Category("Tools")] + public void TestEmptySourceAndDestination() + { + var l1 = Path.Combine(TARGETFOLDER, "empty_src"); + var l2 = Path.Combine(TARGETFOLDER, "l2"); + + Directory.CreateDirectory(l1); + Directory.CreateDirectory(l2); + + var args = new string[] { $"file://{l1}", $"file://{l2}", "--confirm" }; + + var async_call = RemoteSynchronization.Program.Main(args); + var return_code = async_call.ConfigureAwait(false).GetAwaiter().GetResult(); + + Assert.AreEqual(0, return_code, "Remote synchronization tool did not return 0."); + Assert.IsTrue(DirectoriesAndContentsAreEqual(l1, l2), "Synchronized directories are not equal"); + } + + /// + /// Test that remote synchronizing an empty source to a non-empty destination deletes the destination files. + /// + [Test] + [Category("Tools")] + public void TestEmptySourceDeletesDestination() + { + var l1 = Path.Combine(TARGETFOLDER, "empty_src"); + var l2 = Path.Combine(TARGETFOLDER, "l2"); + + Directory.CreateDirectory(l1); + Directory.CreateDirectory(l2); + + GenerateTestData(l2, 5, 0, 0, 1024).Wait(); + + var args = new string[] { $"file://{l1}", $"file://{l2}", "--confirm" }; + + var async_call = RemoteSynchronization.Program.Main(args); + var return_code = async_call.ConfigureAwait(false).GetAwaiter().GetResult(); + + Assert.AreEqual(0, return_code, "Remote synchronization tool did not return 0."); + Assert.IsTrue(DirectoriesAndContentsAreEqual(l1, l2), "Synchronized directories are not equal"); + } + + /// + /// Test that remote synchronizing an empty source to a non-empty destination renames the destination files when the `--retention` option is used. + /// + [Test] + [Category("Tools")] + public void TestEmptySourceRenamesDestination() + { + var l1 = Path.Combine(TARGETFOLDER, "empty_src"); + var l2 = Path.Combine(TARGETFOLDER, "l2"); + + Directory.CreateDirectory(l1); + Directory.CreateDirectory(l2); + + GenerateTestData(l2, 5, 0, 0, 1024).Wait(); + + var filelist = Directory.EnumerateFiles(l2).ToList(); + var files = filelist.Select(x => File.ReadAllBytes(x)).ToList(); + + var args = new string[] { $"file://{l1}", $"file://{l2}", "--confirm", "--retention" }; + + var async_call = RemoteSynchronization.Program.Main(args); + var return_code = async_call.ConfigureAwait(false).GetAwaiter().GetResult(); + + Assert.AreEqual(0, return_code, "Remote synchronization tool did not return 0."); + + var newfilelist = Directory.EnumerateFiles(l2).ToList(); + foreach (var (name, contents) in filelist.Zip(files)) + { + var filename = Path.GetFileName(name); + var newcontents = File.ReadAllBytes(newfilelist.FirstOrDefault(x => x.EndsWith(filename))); + Assert.AreEqual(contents, newcontents, "File contents are not equal"); + } + } + + /// + /// Tests passing all arguments to the main method of the remote synchronization tool. + /// + [Test] + public void TestMainMethodParsesArgumentsCorrectly() + { + string[][] testCases = + { + ["source", "destination", "--parse-arguments-only"], + ["source", "destination", "--parse-arguments-only", "--dry-run"], + ["source", "destination", "--parse-arguments-only", "--force"], + ["source", "destination", "--parse-arguments-only", "--dry-run", "--force"], + ["source", "destination", "--parse-arguments-only", "--verify-contents"], + ["source", "destination", "--parse-arguments-only", "--verify-get-after-put"], + ["source", "destination", "--parse-arguments-only", "--retry", "3"], + ["source", "destination", "--parse-arguments-only", "--log-level", "Debug"], + ["source", "destination", "--parse-arguments-only", "--log-level", "Information"], + ["source", "destination", "--parse-arguments-only", "--log-level", "Profiling"], + ["source", "destination", "--parse-arguments-only", "--log-level", "Verbose"], + ["source", "destination", "--parse-arguments-only", "--log-file", "somefile.log"], + ["source", "destination", "--parse-arguments-only", "--progress"], + ["source", "destination", "--parse-arguments-only", "--retention"], + ["source", "destination", "--parse-arguments-only", "--confirm"], + ["source", "destination", "--parse-arguments-only", "--global-options", "someglobalkey=someglobalvalue", "anotherglobalkey=anotherglobalvalue"], + ["source", "destination", "--parse-arguments-only", "--src-options", "somesrckey=somesrcvalue", "anothersrckey=anothersrcvalue"], + ["source", "destination", "--parse-arguments-only", "--dst-options", "somedstkey=somedstvalue", "anotherdstkey=anotherdstvalue"], + [ + "source", "destination", "--parse-arguments-only", + "--dry-run", "--force", "--verify-contents", "--retry", "3", "--log-level", "Debug", "--log-file", "somefile.log", "--progress", "--retention", "--confirm", + "--global-options", "someglobalkey=someglobalvalue", "anotherglobalkey=anotherglobalvalue", + "--src-options", "somesrckey=somesrcvalue", "anothersrckey=anothersrcvalue", + "--dst-options", "somedstkey=somedstvalue", "anotherdstkey=anotherdstvalue" + ], + }; + + foreach (var args in testCases) + { + int result = RemoteSynchronization.Program.Main(args).ConfigureAwait(false).GetAwaiter().GetResult(); + Assert.AreEqual(0, result, $"Failed for args: {string.Join(" ", args)}"); + } + + int failed_result = RemoteSynchronization.Program.Main(["source", "destination", "--bogus-option"]).ConfigureAwait(false).GetAwaiter().GetResult(); + Assert.AreEqual(1, failed_result, "Invalid option did not return 1"); + } + + /// + /// Tests the original inded use of the remote synchronization tool on an empty destination. + /// + [Test] + [Category("Tools")] + public void TestRemoteSynchronization() + { + var l1 = Path.Combine(TARGETFOLDER, "l1"); + var l2 = Path.Combine(TARGETFOLDER, "l2"); + var l1r = Path.Combine(RESTOREFOLDER, "l1_restore"); + var l2r = Path.Combine(RESTOREFOLDER, "l2_restore"); + + var options = TestOptions; + + var now = DateTime.Now; + GenerateTestData(DATAFOLDER, 10, 3, 3, 1024 * 1024).Wait(); + Console.WriteLine($"Generated test data in {DATAFOLDER} in {DateTime.Now - now}"); + + // Create the directories if they do not exist + foreach (var p in new string[] { l1, l2, l1r, l2r }) + { + if (!SystemIO.IO_OS.DirectoryExists(p)) + SystemIO.IO_OS.DirectoryCreate(p); + } + + // Backup the first level + using (var c = new Controller($"file://{l1}", options, null)) + { + now = DateTime.Now; + var results = c.Backup([DATAFOLDER]); + Assert.AreEqual(0, results.Errors.Count()); + Assert.AreEqual(0, results.Warnings.Count()); + Console.WriteLine($"Backed up {results.AddedFiles} files to {l1} in {DateTime.Now - now}"); + } + + // Call the tool + now = DateTime.Now; + var exe = RemoteSynchronization.Program.Main; + string[] args = [ + $"file://{l1}", $"file://{l2}", + "--global-options", ..options.Select(x => $"{x.Key}={x.Value}"), + // Pass along multi token options to test that the parser won't fail + "--src-options", "ssh-accept-any-fingerprints=true", "ssh-keyfile=/path/to/keyfile", + "--dst-options", "some-other-key=value", "another-key=value2", + "--confirm" + ]; + var async_call = exe(args); + async_call.Wait(); + Assert.AreEqual(0, async_call.Result, "Remote synchronization tool did not return 0."); + Console.WriteLine($"Remote synchronization tool returned 0 in {DateTime.Now - now}"); + + // Verify that the directories are equal + Assert.IsTrue(DirectoriesAndContentsAreEqual(l1, l2), "Synchronized directories are not equal"); + + // Try to restore the first level + options["restore-path"] = l1r; + using (var c = new Controller($"file://{l1}", options, null)) + { + now = DateTime.Now; + var results = c.Restore([Path.Combine(DATAFOLDER, "*")]); + Assert.AreEqual(0, results.Errors.Count()); + Assert.AreEqual(0, results.Warnings.Count()); + Console.WriteLine($"Restored {results.RestoredFiles} files to {options["restore-path"]} in {DateTime.Now - now}"); + } + Assert.IsTrue(DirectoriesAndContentsAreEqual(DATAFOLDER, l1r), "Restored first level files is not equal to original files"); + + // Try to restore the second level + options["restore-path"] = l2r; + using (var c = new Controller($"file://{l2}", options, null)) + { + now = DateTime.Now; + var results = c.Restore([]); + Assert.AreEqual(0, results.Errors.Count()); + Assert.AreEqual(0, results.Warnings.Count()); + Console.WriteLine($"Restored {results.RestoredFiles} files to {options["restore-path"]} in {DateTime.Now - now}"); + } + Assert.IsTrue(DirectoriesAndContentsAreEqual(DATAFOLDER, l2r), "Restored second level files is not equal to original files"); + + // Delete the l2r directory + SystemIO.IO_OS.DirectoryDelete(l2r, true); + + // Delete one file from the l2 backup + var files = Directory.EnumerateFiles(l2).ToList(); + File.Delete(files.First()); + + // Check that the restore fails + options["restore-path"] = l2r; + using (var c = new Controller($"file://{l2}", options, null)) + { + now = DateTime.Now; + try + { + var results = c.Restore([]); + } + catch (RemoteListVerificationException) + { + Console.WriteLine($"Failed (as expected) to restore files to {options["restore-path"]} in {DateTime.Now - now}"); + } + } + + // Run the tool again to copy the missing file + now = DateTime.Now; + async_call = exe(args); + async_call.Wait(); + Assert.AreEqual(0, async_call.Result, "Remote synchronization tool did not return 0."); + Console.WriteLine($"Remote synchronization tool returned 0 in {DateTime.Now - now}"); + + // Try to restore the second level again + options["restore-path"] = l2r; + using (var c = new Controller($"file://{l2}", options, null)) + { + now = DateTime.Now; + var results = c.Restore([]); + Assert.AreEqual(0, results.Errors.Count()); + Assert.AreEqual(0, results.Warnings.Count()); + Console.WriteLine($"Restored {results.RestoredFiles} files to {options["restore-path"]} in {DateTime.Now - now}"); + } + + Assert.IsTrue(DirectoriesAndContentsAreEqual(DATAFOLDER, l2r), "Restored second level files is not equal to original files"); + + // Add some more files to the source + GenerateTestData(Path.Combine(DATAFOLDER, "brand_new_files"), 5, 2, 2, 1024 * 1024).Wait(); + + // Backup the new files to l1 + using (var c = new Controller($"file://{l1}", options, null)) + { + now = DateTime.Now; + var results = c.Backup([DATAFOLDER]); + Assert.AreEqual(0, results.Errors.Count()); + Assert.AreEqual(0, results.Warnings.Count()); + Console.WriteLine($"Backed up {results.AddedFiles} files to {l1} in {DateTime.Now - now}"); + } + + // Run the tool again to copy the new files + now = DateTime.Now; + async_call = exe(args); + async_call.Wait(); + Assert.AreEqual(0, async_call.Result, "Remote synchronization tool did not return 0."); + Console.WriteLine($"Remote synchronization tool returned 0 in {DateTime.Now - now}"); + + // Try to restore the second level again + options["restore-path"] = l2r; + using (var c = new Controller($"file://{l2}", options, null)) + { + now = DateTime.Now; + var results = c.Restore([]); + Assert.AreEqual(0, results.Errors.Count()); + Assert.AreEqual(0, results.Warnings.Count()); + Console.WriteLine($"Restored {results.RestoredFiles} files to {options["restore-path"]} in {DateTime.Now - now}"); + } + + Assert.IsTrue(DirectoriesAndContentsAreEqual(DATAFOLDER, l2r), "Restored second level files is not equal to original files"); + + // Delete the l2r directory + SystemIO.IO_OS.DirectoryDelete(l2r, true); + + // Remove a directory from the source + SystemIO.IO_OS.DirectoryDelete(Path.Combine(DATAFOLDER, "dir_0"), true); + + // Backup the new files to l1 + using (var c = new Controller($"file://{l1}", options, null)) + { + now = DateTime.Now; + var results = c.Backup([DATAFOLDER]); + Assert.AreEqual(0, results.Errors.Count()); + Assert.AreEqual(0, results.Warnings.Count()); + Console.WriteLine($"Backed up {results.AddedFiles} files to {l1} in {DateTime.Now - now}"); + } + + // Compact the backup + using (var c = new Controller($"file://{l1}", options, null)) + { + now = DateTime.Now; + var results = c.Compact(); + Assert.AreEqual(0, results.Errors.Count()); + Assert.AreEqual(0, results.Warnings.Count()); + Console.WriteLine($"Compacted backup in {DateTime.Now - now}"); + } + + // Run the tool again to copy the new files + now = DateTime.Now; + async_call = exe(args); + async_call.Wait(); + Assert.AreEqual(0, async_call.Result, "Remote synchronization tool did not return 0."); + + // Try to restore the second level again + options["restore-path"] = l2r; + using (var c = new Controller($"file://{l2}", options, null)) + { + now = DateTime.Now; + var results = c.Restore([]); + Assert.AreEqual(0, results.Errors.Count()); + Assert.AreEqual(0, results.Warnings.Count()); + Console.WriteLine($"Restored {results.RestoredFiles} files to {options["restore-path"]} in {DateTime.Now - now}"); + } + + Assert.IsTrue(DirectoriesAndContentsAreEqual(DATAFOLDER, l2r), "Restored second level files is not equal to original files"); + + // Delete the l2r directory + SystemIO.IO_OS.DirectoryDelete(l2r, true); + + // Perform a forced synchronization with retention to check that retention doesn't break a restore + now = DateTime.Now; + async_call = exe([.. args, "--force", "--retention"]); + async_call.Wait(); + Assert.AreEqual(0, async_call.Result, "Remote synchronization tool did not return 0."); + + // Try to restore the second level again + options["restore-path"] = l2r; + using (var c = new Controller($"file://{l2}", options, null)) + { + now = DateTime.Now; + var results = c.Restore([]); + Assert.AreEqual(0, results.Errors.Count()); + Assert.AreEqual(0, results.Warnings.Count()); + Console.WriteLine($"Restored {results.RestoredFiles} files to {options["restore-path"]} in {DateTime.Now - now}"); + } + + Assert.IsTrue(DirectoriesAndContentsAreEqual(DATAFOLDER, l2r), "Restored second level files is not equal to original files"); + } + + /// + /// Tests that the remote synchronization tool verifies the contents of the files. + /// + [Test] + [Category("Tools")] + public void TestVerifies() + { + var l1 = Path.Combine(TARGETFOLDER, "l1"); + var l2 = Path.Combine(TARGETFOLDER, "l2"); + + Directory.CreateDirectory(l1); + Directory.CreateDirectory(l2); + + GenerateTestData(l1, 5, 0, 0, 1024).Wait(); + + var filenames = Directory.EnumerateFiles(l1).Take(2).ToList(); + var first_file = filenames.First(); + var second_file = filenames.Skip(1).First(); + + File.Copy(first_file, Path.Combine(l2, Path.GetFileName(first_file))); + File.Copy(second_file, Path.Combine(l2, Path.GetFileName(second_file))); + + // Touch the first file to give it a different timestamp + File.SetLastWriteTime(first_file, DateTime.Now.AddMinutes(1)); + + // Modify the second file to give it different contents, but the same size + var second_file_contents = File.ReadAllBytes(second_file); + second_file_contents[0] = (byte)(second_file_contents[0] + 1); + File.WriteAllBytes(second_file, second_file_contents); + + var args = new string[] { $"file://{l1}", $"file://{l2}", "--confirm", "--verify-contents", "--verify-get-after-put" }; + + var async_call = RemoteSynchronization.Program.Main(args); + var return_code = async_call.ConfigureAwait(false).GetAwaiter().GetResult(); + + Assert.AreEqual(0, return_code, "Remote synchronization tool did not return 0."); + Assert.IsTrue(DirectoriesAndContentsAreEqual(l1, l2), "Synchronized directories are not equal"); + } + + // + // Helper methods + // + + /// + /// Compares two directories and their contents. + /// + /// The first directory. + /// The second directory. + /// `true` if the two directories contain exactly the same files and if the contents of the files are equivalent. `false` otherwise. + public static bool DirectoriesAndContentsAreEqual(string d1, string d2) + { + // Recursively get the files in the two directories + var f1s = Directory.EnumerateFiles(d1, "*", SearchOption.AllDirectories).Select(x => x[(d1.Length + 1)..]).OrderDescending(); + var f2s = Directory.EnumerateFiles(d2, "*", SearchOption.AllDirectories).Select(x => x[(d2.Length + 1)..]).OrderDescending(); + + // If the two directories do not contain the same files, return false + if (!f1s.SequenceEqual(f2s)) + return false; + + // Check that each file pair exist and have the same content + foreach (var f1 in f1s) + { + var f1full = Path.Combine(d1, f1); + if (!File.Exists(f1full)) + return false; + + var f2full = Path.Combine(d2, f1); + if (!File.Exists(f2full)) + return false; + + var c1 = File.ReadAllText(f1full); + var c2 = File.ReadAllText(f2full); + + if (!c1.SequenceEqual(c2)) + return false; + } + + return true; + } + + /// + /// Generates test data in the specified directory. + /// + /// The directory to fill with the generated data. + /// How many files the directory should have. + /// How many subdirectories the directory should have. + /// How deep the number of subdirectories within subdirectories should go. + /// The maximum size of the files to generate. + public static async Task GenerateTestData(string dir, int n_files, int n_dirs, int n_levels, int max_file_size) + { + if (!SystemIO.IO_OS.DirectoryExists(dir)) + SystemIO.IO_OS.DirectoryCreate(dir); + + var fs = Enumerable.Range(0, n_files) + .Select(i => GenerateTestFile(dir, i, max_file_size)); + var ds = n_levels > 0 ? + Enumerable.Range(0, n_dirs) + .Select(i => + { + var subdir = Path.Combine(dir, $"dir_{i}"); + return GenerateTestData(subdir, n_files, n_dirs, n_levels - 1, max_file_size); + }) + : []; + + await Task.WhenAll([.. fs, .. ds]); + } + + public static async Task GenerateTestFile(string dir, int i, int max_file_size) + { + var rnd = new Random(); + var file = Path.Combine(dir, $"file_{i}.txt"); + var size = rnd.Next(1, max_file_size); + var data = new byte[size]; + rnd.NextBytes(data); + await File.WriteAllBytesAsync(file, data); + } + + } +} \ No newline at end of file diff --git a/Duplicati/UnitTest/UtilityTests.cs b/Duplicati/UnitTest/UtilityTests.cs index c4dd5792c..35ca8b92c 100644 --- a/Duplicati/UnitTest/UtilityTests.cs +++ b/Duplicati/UnitTest/UtilityTests.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Utility; @@ -349,8 +349,7 @@ namespace Duplicati.UnitTest string[] duplicateItems = { "A", "c" }; // Test with default comparer. - ISet actualDuplicateItems; - ISet actualUniqueItems = Utility.GetUniqueItems(collection, out actualDuplicateItems); + var actualUniqueItems = Utility.GetUniqueItems(collection, out var actualDuplicateItems); CollectionAssert.AreEquivalent(uniqueItems, actualUniqueItems); CollectionAssert.AreEquivalent(duplicateItems, actualDuplicateItems); diff --git a/Duplicati/UnitTest/ZipFallbackTest.cs b/Duplicati/UnitTest/ZipFallbackTest.cs new file mode 100644 index 000000000..8607d7192 --- /dev/null +++ b/Duplicati/UnitTest/ZipFallbackTest.cs @@ -0,0 +1,82 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using Duplicati.Library.Interface; +using NUnit.Framework; + +namespace Duplicati.UnitTest +{ + public class ZipFallbackTest : BasicSetupHelper + { + [Test] + [Category("Targeted")] + public void RunCommands() + { + var testopts = TestOptions; + testopts["zip-compression-method"] = "lzma"; + testopts["zip-compression-library"] = "SharpCompress"; + testopts["blocksize"] = "50kb"; + + var data = new byte[1024 * 1024 * 10]; + File.WriteAllBytes(Path.Combine(DATAFOLDER, "a"), data); + using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null)) + { + var r = c.Backup(new string[] { DATAFOLDER }); + Assert.AreEqual(0, r.Errors.Count()); + Assert.AreEqual(0, r.Warnings.Count()); + } + + // Delete the local database + File.Delete(DBFILE); + + // Switch back to built-in compression + testopts.Remove("zip-compression-method"); + testopts["zip-compression-library"] = "Auto"; + + // Recreate the database + using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null)) + { + var r = c.Repair(); + Assert.AreEqual(0, r.Errors.Count()); + // Ensure that we get warnings for the fallback, one for the dlist and one for the dindex + Assert.AreEqual(2, r.Warnings.Count()); + Assert.That(r.Warnings.All(x => x.Contains("lzma", StringComparison.OrdinalIgnoreCase))); + } + + // Restore files + using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts.Expand(new { restore_path = RESTOREFOLDER }), null)) + { + var r = c.Restore(null); + Assert.AreEqual(0, r.Errors.Count()); + // Ensure that we get warnings for the fallback, one for the dblock file + Assert.AreEqual(1, r.Warnings.Count()); + Assert.That(r.Warnings.All(x => x.Contains("lzma", StringComparison.OrdinalIgnoreCase))); + } + + TestUtils.AssertDirectoryTreesAreEquivalent(DATAFOLDER, RESTOREFOLDER, true, "Restore"); + + } + } +} diff --git a/Duplicati/WebserverCore/DayOfWeekStringEnumConverter.cs b/Duplicati/WebserverCore/DayOfWeekStringEnumConverter.cs index 27ebec38a..c0e93e3f6 100644 --- a/Duplicati/WebserverCore/DayOfWeekStringEnumConverter.cs +++ b/Duplicati/WebserverCore/DayOfWeekStringEnumConverter.cs @@ -32,16 +32,18 @@ public class DayOfWeekStringEnumConverter : JsonConverterFactory if (reader.TokenType != JsonTokenType.String) throw new JsonException(); - return reader.GetString()?.Trim()?.ToLowerInvariant() switch + var value = reader.GetString()?.Trim()?.ToLowerInvariant(); + + return value switch { - "sun" => DayOfWeek.Sunday, - "mon" => DayOfWeek.Monday, - "tue" => DayOfWeek.Tuesday, - "wed" => DayOfWeek.Wednesday, - "thu" => DayOfWeek.Thursday, - "fri" => DayOfWeek.Friday, - "sat" => DayOfWeek.Saturday, - _ => throw new JsonException() + "sun" or "sunday" => DayOfWeek.Sunday, + "mon" or "monday" => DayOfWeek.Monday, + "tue" or "tuesday" => DayOfWeek.Tuesday, + "wed" or "wednesday" => DayOfWeek.Wednesday, + "thu" or "thursday" => DayOfWeek.Thursday, + "fri" or "friday" => DayOfWeek.Friday, + "sat" or "saturday" => DayOfWeek.Saturday, + _ => throw new JsonException($"Invalid day of week: {value}"), }; } @@ -71,7 +73,7 @@ public class DayOfWeekStringEnumConverter : JsonConverterFactory writer.WriteStringValue("sat"); break; default: - throw new JsonException(); + throw new JsonException($"Invalid day of week: {value}"); } writer.WriteStringValue(value.ToString()); } diff --git a/Duplicati/WebserverCore/Dto/GetDbPathDto.cs b/Duplicati/WebserverCore/Dto/GetDbPathDto.cs index 81850193a..b6cc3b0f9 100644 --- a/Duplicati/WebserverCore/Dto/GetDbPathDto.cs +++ b/Duplicati/WebserverCore/Dto/GetDbPathDto.cs @@ -25,4 +25,4 @@ namespace Duplicati.WebserverCore.Dto; /// /// Whether the database path exists. /// The database path. -public sealed record GetDbPathDto(bool Exists, string Path); +public sealed record GetDbPathDto(bool Exists, string? Path); diff --git a/Duplicati/WebserverCore/Duplicati.WebserverCore.csproj b/Duplicati/WebserverCore/Duplicati.WebserverCore.csproj index be4dbc9f8..93da37923 100644 --- a/Duplicati/WebserverCore/Duplicati.WebserverCore.csproj +++ b/Duplicati/WebserverCore/Duplicati.WebserverCore.csproj @@ -17,8 +17,6 @@ - - diff --git a/Duplicati/WebserverCore/DuplicatiWebserver.cs b/Duplicati/WebserverCore/DuplicatiWebserver.cs index 2010d70b4..7087bf3f0 100644 --- a/Duplicati/WebserverCore/DuplicatiWebserver.cs +++ b/Duplicati/WebserverCore/DuplicatiWebserver.cs @@ -1,4 +1,4 @@ -// Copyright (C) 2025, The Duplicati Team +// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a @@ -48,6 +48,11 @@ public class DuplicatiWebserver /// The log tag for this class ///
private static readonly string LOGTAG = Library.Logging.Log.LogTagFromType(); + /// + /// The name of the CORS policy + /// + public const string CorsPolicyName = "CustomCorsPolicy"; + /// /// The configuration for the server /// @@ -68,6 +73,16 @@ public class DuplicatiWebserver ///
public required int Port { get; init; } + /// + /// The interfaces being listened to + /// + public required string Interface { get; init; } + + /// + /// The port the server is listening on + /// + public required bool CorsEnabled { get; init; } + /// /// The task that will be set when the server is terminated /// @@ -93,6 +108,7 @@ public class DuplicatiWebserver /// The allowed hostnames /// If static files should be disabled /// The paths to serve as SPAs + /// The origins to allow for CORS public record InitSettings( string WebRoot, int Port, @@ -101,7 +117,8 @@ public class DuplicatiWebserver string Servername, IEnumerable AllowedHostnames, bool DisableStaticFiles, - IEnumerable SPAPaths + IEnumerable SPAPaths, + IEnumerable CorsOrigins ); /// @@ -253,9 +270,28 @@ public class DuplicatiWebserver builder.Services.AddHttpClient(); + var useCors = settings.CorsOrigins != null && settings.CorsOrigins.Any(); + if (useCors) + { + builder.Services.AddCors(options => + { + options.AddPolicy( + name: CorsPolicyName, + policy => + { + policy.WithOrigins(settings.CorsOrigins!.ToArray()) + .AllowAnyHeader() + .AllowAnyMethod(); + }); + }); + } + var app = builder.Build(); HttpClientHelper.Configure(app.Services.GetRequiredService()); + if (useCors) + app.UseCors(CorsPolicyName); + app.UseAuthentication(); app.UseAuthorization(); @@ -304,11 +340,20 @@ public class DuplicatiWebserver // Preload static system info, for better first-load experience var _ = Task.Run(() => app.Services.GetRequiredService().GetSystemInfo(null)); + // Get a string description of the listen interface used + var listenInterface = settings.Interface == System.Net.IPAddress.Any + ? "*" + : settings.Interface == System.Net.IPAddress.Loopback + ? "localhost" + : settings.Interface.ToString(); + return new DuplicatiWebserver() { Configuration = builder.Configuration, App = app, - Port = settings.Port + Port = settings.Port, + Interface = listenInterface, + CorsEnabled = useCors }; } @@ -345,7 +390,7 @@ public class DuplicatiWebserver public Task Start() { App.MapHealthChecks("/health"); - App.AddEndpoints() + App.AddEndpoints(CorsEnabled) .UseNotifications("/notifications"); return TerminationTask = App.RunAsync(); diff --git a/Duplicati/WebserverCore/Endpoints/V1/Backup/BackupGet.cs b/Duplicati/WebserverCore/Endpoints/V1/Backup/BackupGet.cs index e845a99d5..166a494cd 100644 --- a/Duplicati/WebserverCore/Endpoints/V1/Backup/BackupGet.cs +++ b/Duplicati/WebserverCore/Endpoints/V1/Backup/BackupGet.cs @@ -292,7 +292,7 @@ public class BackupGet : IEndpointV1 } private static Dto.IsDbUsedElsewhereDto ExecuteGetIsdbUsedElsewhere(IBackup bk) - => new Dto.IsDbUsedElsewhereDto(Library.Main.DatabaseLocator.IsDatabasePathInUse(bk.DBPath)); + => new Dto.IsDbUsedElsewhereDto(Library.Main.CLIDatabaseLocator.IsDatabasePathInUse(bk.DBPath)); private static Dto.IsBackupActiveDto ExecuteGetIsActive(IWorkerThreadsManager workerThreadsManager, IBackup bk) { diff --git a/Duplicati/WebserverCore/Endpoints/V1/Backups.cs b/Duplicati/WebserverCore/Endpoints/V1/Backups.cs index 096061b6f..84783a1f3 100644 --- a/Duplicati/WebserverCore/Endpoints/V1/Backups.cs +++ b/Duplicati/WebserverCore/Endpoints/V1/Backups.cs @@ -206,7 +206,7 @@ public class Backups : IEndpointV1 { if (existingDb) { - backup.DBPathSetter = Library.Main.DatabaseLocator.GetDatabasePath(data.Backup.TargetURL, null, false, false); + backup.DBPathSetter = Library.Main.CLIDatabaseLocator.GetDatabasePathForCLI(data.Backup.TargetURL, null, false, false); if (string.IsNullOrWhiteSpace(data.Backup.DBPath)) throw new Exception("Unable to find remote db path?"); } diff --git a/Duplicati/WebserverCore/Endpoints/V1/Filesystem.cs b/Duplicati/WebserverCore/Endpoints/V1/Filesystem.cs index 9a7625a0d..bb1e9509d 100644 --- a/Duplicati/WebserverCore/Endpoints/V1/Filesystem.cs +++ b/Duplicati/WebserverCore/Endpoints/V1/Filesystem.cs @@ -254,11 +254,11 @@ public class Filesystem : IEndpointV1 { var attr = SystemIO.IO_OS.GetFileAttributes(s); var isSymlink = SystemIO.IO_OS.IsSymlink(s, attr); - var isFolder = (attr & FileAttributes.Directory) != 0; + var isFolder = attr.HasFlag(FileAttributes.Directory); var isFile = !isFolder; - var isHidden = (attr & FileAttributes.Hidden) != 0; - bool isSystem = (attr & FileAttributes.System) != 0; - bool isTemporary = (attr & FileAttributes.Temporary) != 0; + var isHidden = attr.HasFlag(FileAttributes.Hidden); + bool isSystem = attr.HasFlag(FileAttributes.System); + bool isTemporary = attr.HasFlag(FileAttributes.Temporary); long fileSize = -1; var accessible = isFile || canAccess(s); diff --git a/Duplicati/WebserverCore/Endpoints/V1/HyperV.cs b/Duplicati/WebserverCore/Endpoints/V1/HyperV.cs index cde404bae..98f8fed9e 100644 --- a/Duplicati/WebserverCore/Endpoints/V1/HyperV.cs +++ b/Duplicati/WebserverCore/Endpoints/V1/HyperV.cs @@ -21,6 +21,7 @@ using System.Runtime.Versioning; using System.Security.Principal; using Duplicati.Library.Snapshots; +using Duplicati.Library.Snapshots.Windows; using Duplicati.WebserverCore.Abstractions; using Duplicati.WebserverCore.Exceptions; using Microsoft.AspNetCore.Mvc; @@ -58,7 +59,7 @@ public class HyperV : IEndpointV1 { if (string.IsNullOrEmpty(key)) { - hypervUtility.QueryHyperVGuestsInfo(); + hypervUtility.QueryHyperVGuestsInfo(SnapshotProvider.AlphaVSS); return hypervUtility.Guests.Select(x => new Dto.TreeNodeDto() { id = x.ID.ToString(), @@ -77,7 +78,7 @@ public class HyperV : IEndpointV1 } else { - hypervUtility.QueryHyperVGuestsInfo(true); + hypervUtility.QueryHyperVGuestsInfo(SnapshotProvider.AlphaVSS, true); var foundVMs = hypervUtility.Guests.FindAll(x => x.ID.Equals(new Guid(key))); if (foundVMs.Count != 1) diff --git a/Duplicati/WebserverCore/Endpoints/V1/MSSql.cs b/Duplicati/WebserverCore/Endpoints/V1/MSSql.cs index 0a2b98679..b4f4e6607 100644 --- a/Duplicati/WebserverCore/Endpoints/V1/MSSql.cs +++ b/Duplicati/WebserverCore/Endpoints/V1/MSSql.cs @@ -21,6 +21,7 @@ using System.Runtime.Versioning; using System.Security.Principal; using Duplicati.Library.Snapshots; +using Duplicati.Library.Snapshots.Windows; using Duplicati.WebserverCore.Abstractions; using Duplicati.WebserverCore.Exceptions; using Microsoft.AspNetCore.Mvc; @@ -56,7 +57,7 @@ public class MSSql : IEndpointV1 try { - mssqlUtility.QueryDBsInfo(); + mssqlUtility.QueryDBsInfo(SnapshotProvider.AlphaVSS); if (string.IsNullOrEmpty(key)) { diff --git a/Duplicati/WebserverCore/Endpoints/V1/RemoteOperation.cs b/Duplicati/WebserverCore/Endpoints/V1/RemoteOperation.cs index 938198c51..8771de22d 100644 --- a/Duplicati/WebserverCore/Endpoints/V1/RemoteOperation.cs +++ b/Duplicati/WebserverCore/Endpoints/V1/RemoteOperation.cs @@ -49,7 +49,7 @@ namespace Duplicati.WebserverCore.Endpoints.V1 private static Dto.GetDbPathDto ExecuteDbPath(string uri) { - var path = Library.Main.DatabaseLocator.GetDatabasePath(uri, null, false, false); + var path = CLIDatabaseLocator.GetDatabasePathForCLI(uri, null, false, false); return new Dto.GetDbPathDto(!string.IsNullOrWhiteSpace(path), path); } diff --git a/Duplicati/WebserverCore/Endpoints/V1/ServerSetting.cs b/Duplicati/WebserverCore/Endpoints/V1/ServerSetting.cs index 464652b09..b43f1d46c 100644 --- a/Duplicati/WebserverCore/Endpoints/V1/ServerSetting.cs +++ b/Duplicati/WebserverCore/Endpoints/V1/ServerSetting.cs @@ -19,7 +19,6 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. -using Duplicati.Server; using Duplicati.Server.Database; using Duplicati.WebserverCore.Abstractions; using Duplicati.WebserverCore.Exceptions; diff --git a/Duplicati/WebserverCore/Extensions/ServiceCollectionsExtensions.cs b/Duplicati/WebserverCore/Extensions/ServiceCollectionsExtensions.cs index df6c64b0c..7f3e80ad3 100644 --- a/Duplicati/WebserverCore/Extensions/ServiceCollectionsExtensions.cs +++ b/Duplicati/WebserverCore/Extensions/ServiceCollectionsExtensions.cs @@ -66,6 +66,7 @@ public static class ServiceCollectionsExtensions .AddTransient() .AddTransient() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton(); diff --git a/Duplicati/WebserverCore/Extensions/WebApplicationExtensions.cs b/Duplicati/WebserverCore/Extensions/WebApplicationExtensions.cs index 00485cef9..254551ef9 100644 --- a/Duplicati/WebserverCore/Extensions/WebApplicationExtensions.cs +++ b/Duplicati/WebserverCore/Extensions/WebApplicationExtensions.cs @@ -26,12 +26,12 @@ namespace Duplicati.WebserverCore.Extensions; public static class WebApplicationExtensions { - public static WebApplication AddEndpoints(this WebApplication application) + public static WebApplication AddEndpoints(this WebApplication application, bool useCors) { - return AddV1(application); + return AddV1(application, useCors); } - private static WebApplication AddV1(WebApplication application) + private static WebApplication AddV1(WebApplication application, bool useCors) { var mapperInterfaceType = typeof(IEndpointV1); var endpoints = @@ -46,6 +46,9 @@ public static class WebApplicationExtensions if (!string.IsNullOrWhiteSpace(PreSharedKeyFilter.PreSharedKey)) group = group.AddEndpointFilter(); + if (useCors) + group.RequireCors(DuplicatiWebserver.CorsPolicyName); + foreach (var endpoint in endpoints) { var methodMap = endpoint.GetMethod(nameof(IEndpointV1.Map), BindingFlags.Static | BindingFlags.Public); diff --git a/Duplicati/WebserverCore/Middlewares/LanguageFilter.cs b/Duplicati/WebserverCore/Middlewares/LanguageFilter.cs index 353720ebf..a376baee5 100644 --- a/Duplicati/WebserverCore/Middlewares/LanguageFilter.cs +++ b/Duplicati/WebserverCore/Middlewares/LanguageFilter.cs @@ -18,6 +18,7 @@ // 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.Globalization; using Duplicati.Library.Localization; namespace Duplicati.WebserverCore.Middlewares; @@ -30,7 +31,7 @@ public class LanguageFilter : IEndpointFilter ? LocalizationService.ParseCulture(locale) : null; - using (ci == null ? null : LocalizationService.TemporaryContext(ci)) + using (LocalizationService.TemporaryContext(ci ?? CultureInfo.CurrentUICulture)) return await next(context); } diff --git a/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.Debug.cs b/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.Debug.cs index 57dc64693..dab8f0c08 100644 --- a/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.Debug.cs +++ b/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.Debug.cs @@ -41,6 +41,17 @@ internal static class NpmSpaHelper { var tempPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + // Handle move not working across drive boundaries on Windows + if (OperatingSystem.IsWindows() && Path.GetPathRoot(tempPath) != Path.GetPathRoot(targetPath)) + { + tempPath = Path.GetFullPath(targetPath); + if (targetPath.EndsWith(Path.DirectorySeparatorChar)) + tempPath = tempPath[..-1]; + tempPath += "-tmp"; + if (Directory.Exists(tempPath)) + Directory.Delete(tempPath, true); + } + try { // Download and extract the package @@ -156,7 +167,7 @@ internal static class NpmSpaHelper return null; // Package is not installed, install it - var packageFolder = Path.Combine(basepath, "node_modules", packageId); + var packageFolder = Path.GetFullPath(Path.Combine(basepath, "node_modules", packageId)); if (!Directory.Exists(packageFolder)) return InstallNpmPackage(packageUrl, packageFolder); diff --git a/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs b/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs index db69609da..7c6119076 100644 --- a/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs +++ b/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs @@ -143,7 +143,7 @@ public static class StaticFilesExtensions { // Serve the static file var file = new FileInfo(Path.Combine(spaConfig.BasePath, path.Value.Substring(spaConfig.Prefix.Length).TrimStart('/'))); - if (file.FullName.StartsWith(spaConfig.BasePath) && file.Exists && fileTypeMappings.Mappings.TryGetValue(Path.GetExtension(file.Extension), out var contentType)) + if (file.FullName.StartsWith(spaConfig.BasePath, Library.Utility.Utility.ClientFilenameStringComparison) && file.Exists && fileTypeMappings.Mappings.TryGetValue(Path.GetExtension(file.Extension), out var contentType)) { context.Response.ContentType = contentType; context.Response.StatusCode = 200; diff --git a/Duplicati/WebserverCore/Services/IRemoteControllerHandler.cs b/Duplicati/WebserverCore/Services/IRemoteControllerHandler.cs new file mode 100644 index 000000000..338b098cd --- /dev/null +++ b/Duplicati/WebserverCore/Services/IRemoteControllerHandler.cs @@ -0,0 +1,58 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using Duplicati.Library.RemoteControl; + +namespace Duplicati.WebserverCore.Services; + +/// +/// Interface for handling remote control messages. +/// +public interface IRemoteControllerHandler +{ + /// + /// Prepares metadata for a remote control connection. + /// + /// The initial metadata + /// The metadata to use + Task> OnConnect(Dictionary metadata); + + /// + /// Re-keys the remote control connection. + /// + /// The data to re-key with + /// An awaitable task + Task ReKey(ClaimedClientData data); + + /// + /// Handles a control message. + /// + /// The control message to handle + /// An awaitable task + Task OnControl(KeepRemoteConnection.ControlMessage message); + + /// + /// Handles a command message. + /// + /// The command message to handle + /// An awaitable task + Task OnMessage(KeepRemoteConnection.CommandMessage commandMessage); +} \ No newline at end of file diff --git a/Duplicati/WebserverCore/Services/RemoteControllerHandler.cs b/Duplicati/WebserverCore/Services/RemoteControllerHandler.cs new file mode 100644 index 000000000..6664e2918 --- /dev/null +++ b/Duplicati/WebserverCore/Services/RemoteControllerHandler.cs @@ -0,0 +1,120 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Net.Http.Headers; +using Duplicati.Library.Logging; +using Duplicati.Library.RemoteControl; +using Duplicati.Library.RestAPI; +using Duplicati.Server.Database; +using Duplicati.WebserverCore.Abstractions; +using Newtonsoft.Json; + +namespace Duplicati.WebserverCore.Services; + +/// +/// Controller for handling remote control messages. +/// +/// The connection to the database +/// The HTTP client factory +/// The JWT token provider +public class RemoteControllerHandler(Connection connection, IHttpClientFactory httpClientFactory, IJWTTokenProvider jwtTokenProvider) : IRemoteControllerHandler +{ + /// + /// The log tag for this class. + /// + private static readonly string LOGTAG = Log.LogTagFromType(); + + /// + public Task> OnConnect(Dictionary metadata) + { + metadata["feature:additional-report-url"] = connection.ApplicationSettings.AdditionalReportUrl; + return Task.FromResult(metadata); + } + + /// + public Task ReKey(ClaimedClientData data) + { + var oldCerts = connection.ApplicationSettings.RemoteControlConfig == null + ? null + : JsonConvert.DeserializeObject(connection.ApplicationSettings.RemoteControlConfig)?.ServerCertificates; + + connection.ApplicationSettings.RemoteControlConfig = JsonConvert.SerializeObject(new RemoteControlConfig + { + Token = data.JWT, + ServerCertificates = data.ServerCertificates ?? oldCerts ?? [], + ServerUrl = data.ServerUrl, + CertificateUrl = data.CertificateUrl + }); + + if (!FIXMEGlobal.SettingsEncryptionKeyProvidedExternally) + { + // TODO: Implement changing the encryption key + // if (!string.IsNullOrWhiteSpace(data.LocalEncryptionKey) && data.LocalEncryptionKey != connection.ApplicationSettings.SettingsEncryptionKey) + // connection.ChangeDbKey(keydata.LocalEncryptionKey); + } + + return Task.CompletedTask; + } + + /// + public async Task OnControl(KeepRemoteConnection.ControlMessage message) + { + // Make method async + await Task.CompletedTask; + + Dictionary? result = null; + + Log.WriteMessage(LogMessageType.Verbose, LOGTAG, "OnControl", "Received control message: {0}", message); + try + { + if (string.Equals(message.ControlRequestMessage.Command, ControlRequestMessage.ConfigureReportUrlSet, StringComparison.OrdinalIgnoreCase)) + FIXMEGlobal.DataConnection.ApplicationSettings.AdditionalReportUrl = message.ControlRequestMessage.Parameters.GetValueOrDefault(ControlRequestMessage.ConfigureReportUrlParameter); + + if (string.Equals(message.ControlRequestMessage.Command, ControlRequestMessage.ConfigureReportUrlGet, StringComparison.OrdinalIgnoreCase)) + result = new Dictionary { { ControlRequestMessage.ConfigureReportUrlParameter, FIXMEGlobal.DataConnection.ApplicationSettings.AdditionalReportUrl } }; + } + catch (Exception ex) + { + Log.WriteMessage(LogMessageType.Error, LOGTAG, "OnControl", ex, "Failed to handle control message: {0}", message); + message.Respond(new ControlResponseMessage(false, null, ex.Message)); + return; + } + + message.Respond(new ControlResponseMessage(true, result, null)); + } + + + /// + public async Task OnMessage(KeepRemoteConnection.CommandMessage commandMessage) + { + using var httpClient = httpClientFactory.CreateClient(); + var token = jwtTokenProvider.CreateAccessToken("remote-control", jwtTokenProvider.TemporaryFamilyId, TimeSpan.FromMinutes(2)); + + httpClient.BaseAddress = new Uri($"{(connection.ApplicationSettings.UseHTTPS ? "https" : "http")}://127.0.0.1:{connection.ApplicationSettings.LastWebserverPort}"); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var psk = Middlewares.PreSharedKeyFilter.PreSharedKey; + if (!string.IsNullOrWhiteSpace(psk)) + httpClient.DefaultRequestHeaders.Add(Middlewares.PreSharedKeyFilter.HeaderName, psk); + + await commandMessage.Handle(httpClient); + } +} diff --git a/Duplicati/WebserverCore/Services/RemoteControllerService.cs b/Duplicati/WebserverCore/Services/RemoteControllerService.cs index decefb361..9286f87ff 100644 --- a/Duplicati/WebserverCore/Services/RemoteControllerService.cs +++ b/Duplicati/WebserverCore/Services/RemoteControllerService.cs @@ -19,9 +19,7 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. -using System.Net.Http.Headers; using Duplicati.Library.RemoteControl; -using Duplicati.Library.RestAPI; using Duplicati.Server.Database; using Duplicati.WebserverCore.Abstractions; using Newtonsoft.Json; @@ -32,9 +30,8 @@ namespace Duplicati.WebserverCore.Services; /// Controller for toggling remote control. /// /// The connection to the database -/// The HTTP client factory -/// The JWT token provider -public class RemoteControllerService(Connection connection, IHttpClientFactory httpClientFactory, IJWTTokenProvider jwtTokenProvider) : IRemoteController +/// The remote controller handler +public class RemoteControllerService(Connection connection, IRemoteControllerHandler controllerHandler) : IRemoteController { /// /// Gets a value indicating whether remote control is enabled. @@ -83,58 +80,15 @@ public class RemoteControllerService(Connection connection, IHttpClientFactory h config.CertificateUrl, config.ServerCertificates, CancellationToken.None, - ReKey, - OnMessage + controllerHandler.OnConnect, + controllerHandler.ReKey, + controllerHandler.OnControl, + controllerHandler.OnMessage ); connection.ApplicationSettings.RemoteControlEnabled = true; } - /// - /// Re-keys the remote control connection. - /// - /// The data to re-key with - /// An awaitable task - private Task ReKey(ClaimedClientData data) - { - var oldCerts = connection.ApplicationSettings.RemoteControlConfig == null - ? null - : JsonConvert.DeserializeObject(connection.ApplicationSettings.RemoteControlConfig)?.ServerCertificates; - - connection.ApplicationSettings.RemoteControlConfig = JsonConvert.SerializeObject(new RemoteControlConfig - { - Token = data.JWT, - ServerCertificates = data.ServerCertificates ?? oldCerts ?? [], - ServerUrl = data.ServerUrl, - CertificateUrl = data.CertificateUrl - }); - - if (!FIXMEGlobal.SettingsEncryptionKeyProvidedExternally) - { - // TODO: Implement changing the encryption key - // if (!string.IsNullOrWhiteSpace(data.LocalEncryptionKey) && data.LocalEncryptionKey != connection.ApplicationSettings.SettingsEncryptionKey) - // connection.ChangeDbKey(keydata.LocalEncryptionKey); - } - - return Task.CompletedTask; - } - - /// - /// Handles a command message. - /// - /// The command message to handle - /// An awaitable task - private async Task OnMessage(KeepRemoteConnection.CommandMessage commandMessage) - { - using var httpClient = httpClientFactory.CreateClient(); - var token = jwtTokenProvider.CreateAccessToken("remote-control", jwtTokenProvider.TemporaryFamilyId, TimeSpan.FromMinutes(2)); - - httpClient.BaseAddress = new Uri($"{(connection.ApplicationSettings.UseHTTPS ? "https" : "http")}://127.0.0.1:{connection.ApplicationSettings.LastWebserverPort}"); - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - - await commandMessage.Handle(httpClient); - } - /// public void Disable() { diff --git a/Duplicati/WebserverCore/Services/SystemInfoProvider.cs b/Duplicati/WebserverCore/Services/SystemInfoProvider.cs index 02296cab7..724732457 100644 --- a/Duplicati/WebserverCore/Services/SystemInfoProvider.cs +++ b/Duplicati/WebserverCore/Services/SystemInfoProvider.cs @@ -20,6 +20,7 @@ // DEALINGS IN THE SOFTWARE. using System.Globalization; using Duplicati.Library.AutoUpdater; +using Duplicati.Library.Localization; using Duplicati.Library.RestAPI; using Duplicati.Server; using Duplicati.Server.Serialization.Interface; @@ -205,62 +206,87 @@ public class SystemInfoProvider : ISystemInfoProvider } /// - /// Cached static system information. + /// Creates a new instance of the class. /// - private Lazy _systemInfoBase = new(() => new StaticSystemInformation - { - APIVersion = 1, - PasswordPlaceholder = FIXMEGlobal.PASSWORD_PLACEHOLDER, - ServerVersion = UpdaterManager.SelfVersion.Version, - ServerVersionName = License.VersionNumbers.Version, - ServerVersionType = Library.AutoUpdater.UpdaterManager.SelfVersion.ReleaseType, - RemoteControlRegistrationUrl = Duplicati.Library.RemoteControl.RegisterForRemote.DefaultRegisterationUrl, - StartedBy = FIXMEGlobal.Origin, - DefaultUpdateChannel = Library.AutoUpdater.AutoUpdateSettings.DefaultUpdateChannel.ToString(), - DefaultUsageReportLevel = Library.UsageReporter.Reporter.DefaultReportLevel, - OSType = OperatingSystem.IsWindows() ? "Windows" : OperatingSystem.IsLinux() ? "Linux" : OperatingSystem.IsMacOS() ? "MacOS" : "Unknown", - OSVersion = Library.UsageReporter.OSInfoHelper.PlatformString, - DirectorySeparator = Path.DirectorySeparatorChar, - PathSeparator = Path.PathSeparator, - CaseSensitiveFilesystem = Library.Utility.Utility.IsFSCaseSensitive, - MachineName = Environment.MachineName, - PackageTypeId = Library.AutoUpdater.UpdaterManager.PackageTypeId, - UserName = OperatingSystem.IsWindows() ? System.Security.Principal.WindowsIdentity.GetCurrent().Name : Environment.UserName, - NewLine = Environment.NewLine, - CLRVersion = Environment.Version.ToString(), - Options = Server.Serializable.ServerSettings.Options, - CompressionModules = Server.Serializable.ServerSettings.CompressionModules, - EncryptionModules = Server.Serializable.ServerSettings.EncryptionModules, - BackendModules = Server.Serializable.ServerSettings.BackendModules, - GenericModules = Server.Serializable.ServerSettings.GenericModules, - WebModules = Server.Serializable.ServerSettings.WebModules, - ConnectionModules = Server.Serializable.ServerSettings.ConnectionModules, - ServerModules = Server.Serializable.ServerSettings.ServerModules, - SecretProviderModules = Server.Serializable.ServerSettings.SecretProviderModules, - UsingAlternateUpdateURLs = Library.AutoUpdater.AutoUpdateSettings.UsesAlternateURLs, - LogLevels = Enum.GetNames(typeof(Library.Logging.LogMessageType)), - SpecialFolders = SpecialFolders.Nodes.Select(n => new Dto.SystemInfoDto.SpecialFolderDto { ID = n.id, Path = n.resolvedpath }).ToArray(), - SupportedLocales = Library.Localization.LocalizationService.SupportedCultures + /// The new instance. + private static StaticSystemInformation CreateStaticSystemInformation() + => new StaticSystemInformation + { + APIVersion = 1, + PasswordPlaceholder = FIXMEGlobal.PASSWORD_PLACEHOLDER, + ServerVersion = UpdaterManager.SelfVersion.Version, + ServerVersionName = License.VersionNumbers.VERSION_NAME, + ServerVersionType = UpdaterManager.SelfVersion.ReleaseType, + RemoteControlRegistrationUrl = Library.RemoteControl.RegisterForRemote.DefaultRegisterationUrl, + StartedBy = FIXMEGlobal.Origin, + DefaultUpdateChannel = AutoUpdateSettings.DefaultUpdateChannel.ToString(), + DefaultUsageReportLevel = Library.UsageReporter.Reporter.DefaultReportLevel, + OSType = UpdaterManager.OperatingSystemName, + OSVersion = Library.UsageReporter.OSInfoHelper.PlatformString, + DirectorySeparator = Path.DirectorySeparatorChar, + PathSeparator = Path.PathSeparator, + CaseSensitiveFilesystem = Library.Utility.Utility.IsFSCaseSensitive, + MachineName = Environment.MachineName, + PackageTypeId = UpdaterManager.PackageTypeId, + UserName = OperatingSystem.IsWindows() ? System.Security.Principal.WindowsIdentity.GetCurrent().Name : Environment.UserName, + NewLine = Environment.NewLine, + CLRVersion = Environment.Version.ToString(), + Options = Server.Serializable.ServerSettings.Options, + CompressionModules = Server.Serializable.ServerSettings.CompressionModules, + EncryptionModules = Server.Serializable.ServerSettings.EncryptionModules, + BackendModules = Server.Serializable.ServerSettings.BackendModules, + GenericModules = Server.Serializable.ServerSettings.GenericModules, + WebModules = Server.Serializable.ServerSettings.WebModules, + ConnectionModules = Server.Serializable.ServerSettings.ConnectionModules, + ServerModules = Server.Serializable.ServerSettings.ServerModules, + SecretProviderModules = Server.Serializable.ServerSettings.SecretProviderModules, + UsingAlternateUpdateURLs = AutoUpdateSettings.UsesAlternateURLs, + LogLevels = Enum.GetNames(typeof(Library.Logging.LogMessageType)), + SpecialFolders = SpecialFolders.Nodes.Select(n => new Dto.SystemInfoDto.SpecialFolderDto { ID = n.id, Path = n.resolvedpath }).ToArray(), + SupportedLocales = Library.Localization.LocalizationService.SupportedCultures .Select(x => new Dto.SystemInfoDto.LocaleDto { Code = x, EnglishName = new CultureInfo(x).EnglishName, DisplayName = new CultureInfo(x).NativeName }).ToArray(), - TimeZones = Library.Utility.TimeZoneHelper.GetTimeZones() + TimeZones = Library.Utility.TimeZoneHelper.GetTimeZones() .Select(x => new Dto.SystemInfoDto.TimeZoneDto { ID = x.Id, DisplayName = x.DisplayName, CurrentUTCOffset = x.CurrentUtcOffset.ToString() }), - }); + }; + + /// + /// The lock guarding the _systemInfoBases cache. + /// + private readonly object _lock = new(); + + /// + /// Cached static system information, the key is the language code. + /// + private Dictionary _systemInfoBases = new(StringComparer.OrdinalIgnoreCase); /// public SystemInfoDto GetSystemInfo(CultureInfo? browserlanguage) { browserlanguage ??= CultureInfo.InvariantCulture; - var systeminfo = _systemInfoBase.Value; + var currentLanguage = LocalizationService.CurrentUI?.Culture.Name; + if (string.IsNullOrWhiteSpace(currentLanguage)) + currentLanguage = CultureInfo.CurrentUICulture.Name; + + StaticSystemInformation? systeminfo; + + lock (_lock) + { + if (!_systemInfoBases.TryGetValue(currentLanguage, out systeminfo)) + { + systeminfo = CreateStaticSystemInformation(); + _systemInfoBases[currentLanguage] = systeminfo; + } + } // Return the system information, patch in dynamic values return new SystemInfoDto() @@ -305,7 +331,7 @@ public class SystemInfoProvider : ISystemInfoProvider DisplayName = browserlanguage.NativeName }, SupportedLocales = systeminfo.SupportedLocales, - BrowserLocaleSupported = Library.Localization.LocalizationService.isCultureSupported(browserlanguage), + BrowserLocaleSupported = Library.Localization.LocalizationService.IsCultureSupported(browserlanguage), TimeZones = systeminfo.TimeZones, }; } diff --git a/Duplicati/WindowsService/Program.cs b/Duplicati/WindowsService/Program.cs index ed0762f8b..21b71782a 100644 --- a/Duplicati/WindowsService/Program.cs +++ b/Duplicati/WindowsService/Program.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using System.IO; @@ -24,11 +24,13 @@ using System.Linq; using System.Reflection; using System.ServiceProcess; using Duplicati.Library.AutoUpdater; +using Duplicati.Library.Utility; namespace Duplicati.WindowsService { public class Program { + [STAThread] public static int Main(string[] args) { @@ -40,7 +42,7 @@ namespace Duplicati.WindowsService var uninstall = args != null && args.Any(x => string.Equals("uninstall", x, StringComparison.OrdinalIgnoreCase)); var install_agent = args != null && args.Any(x => string.Equals("install-agent", x, StringComparison.OrdinalIgnoreCase) || string.Equals("install-only-agent", x, StringComparison.OrdinalIgnoreCase)); var uninstall_agent = args != null && args.Any(x => string.Equals("uninstall-agent", x, StringComparison.OrdinalIgnoreCase)); - var help = args != null && args.Any(x => string.Equals("help", x, StringComparison.OrdinalIgnoreCase)); + var help = args != null && HelpOptionExtensions.IsArgumentAnyHelpString(args); if (help) { diff --git a/Executables/net8/Duplicati.CommandLine.SourceTool/Duplicati.CommandLine.SourceTool.csproj b/Executables/net8/Duplicati.CommandLine.SourceTool/Duplicati.CommandLine.SourceTool.csproj new file mode 100644 index 000000000..038a3b98d --- /dev/null +++ b/Executables/net8/Duplicati.CommandLine.SourceTool/Duplicati.CommandLine.SourceTool.csproj @@ -0,0 +1,22 @@ + + + + Exe + net8.0 + SyncTool for Duplicati + Copyright © 2025 Team Duplicati, MIT license + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/Executables/net8/Duplicati.CommandLine.SourceTool/Program.cs b/Executables/net8/Duplicati.CommandLine.SourceTool/Program.cs new file mode 100644 index 000000000..490864a25 --- /dev/null +++ b/Executables/net8/Duplicati.CommandLine.SourceTool/Program.cs @@ -0,0 +1,31 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. +using Duplicati.Library.Crashlog; + +namespace Duplicati.CommandLine.SourceTool.Net8 +{ + // Wrapper class to keep code independent + public static class Program + { + public static int Main(string[] args) + => CrashlogHelper.WrapWithCrashLog(() => SourceTool.Program.Main(args).ConfigureAwait(false).GetAwaiter().GetResult()); + } +} \ No newline at end of file diff --git a/Executables/net8/Duplicati.CommandLine.SyncTool/Duplicati.CommandLine.SyncTool.csproj b/Executables/net8/Duplicati.CommandLine.SyncTool/Duplicati.CommandLine.SyncTool.csproj new file mode 100644 index 000000000..2ce4cf329 --- /dev/null +++ b/Executables/net8/Duplicati.CommandLine.SyncTool/Duplicati.CommandLine.SyncTool.csproj @@ -0,0 +1,22 @@ + + + + Exe + net8.0 + SyncTool for Duplicati + Copyright © 2025 Team Duplicati, MIT license + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/Executables/net8/Duplicati.CommandLine.SyncTool/Program.cs b/Executables/net8/Duplicati.CommandLine.SyncTool/Program.cs new file mode 100644 index 000000000..c0e13737d --- /dev/null +++ b/Executables/net8/Duplicati.CommandLine.SyncTool/Program.cs @@ -0,0 +1,31 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. +using Duplicati.Library.Crashlog; + +namespace Duplicati.CommandLine.SyncTool.Net8 +{ + // Wrapper class to keep code independent + public static class Program + { + public static int Main(string[] args) + => CrashlogHelper.WrapWithCrashLog(() => RemoteSynchronization.Program.Main(args).ConfigureAwait(false).GetAwaiter().GetResult()); + } +} \ No newline at end of file diff --git a/LiveTests/Duplicati.Backend.Tests/B2/B2Tests.cs b/LiveTests/Duplicati.Backend.Tests/B2/B2Tests.cs new file mode 100644 index 000000000..d45bf2774 --- /dev/null +++ b/LiveTests/Duplicati.Backend.Tests/B2/B2Tests.cs @@ -0,0 +1,46 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +namespace Duplicati.Backend.Tests.B2; + +/// +/// Backblaze B2 Tests +/// +[TestClass] +public sealed class B2Tests : BaseTest +{ + [TestMethod] + public Task TestB2() + { + CheckRequiredEnvironment(["TESTCREDENTIAL_B2_BUCKET", "TESTCREDENTIAL_B2_FOLDER", "TESTCREDENTIAL_B2_USERNAME", "TESTCREDENTIAL_B2_PASSWORD"]); + + var exitCode = CommandLine.BackendTester.Program.Main( + new[] + { + $"b2://{Environment.GetEnvironmentVariable("TESTCREDENTIAL_B2_BUCKET")}/{Environment.GetEnvironmentVariable("TESTCREDENTIAL_B2_FOLDER")}?auth-username={Uri.EscapeDataString(Environment.GetEnvironmentVariable("TESTCREDENTIAL_B2_USERNAME")!)}&auth-password={Uri.EscapeDataString(Environment.GetEnvironmentVariable("TESTCREDENTIAL_B2_PASSWORD")!)}", + + }.Concat(Parameters.GlobalTestParameters).ToArray()); + + if (exitCode != 0) Assert.Fail("BackendTester is returning non-zero exit code, check logs for details"); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/LiveTests/Duplicati.Backend.Tests/Box/BoxTests.cs b/LiveTests/Duplicati.Backend.Tests/Box/BoxTests.cs new file mode 100644 index 000000000..d393a2118 --- /dev/null +++ b/LiveTests/Duplicati.Backend.Tests/Box/BoxTests.cs @@ -0,0 +1,46 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +namespace Duplicati.Backend.Tests.Box; + +/// +/// Box.com Tests +/// +[TestClass] +public sealed class BoxTests : BaseTest +{ + [TestMethod] + public Task TestBox() + { + CheckRequiredEnvironment(["TESTCREDENTIAL_BOX_FOLDER", "TESTCREDENTIAL_BOX_AUTHID"]); + + var exitCode = CommandLine.BackendTester.Program.Main( + new[] + { + $"box://{Environment.GetEnvironmentVariable("TESTCREDENTIAL_BOX_FOLDER")}/?authid={Uri.EscapeDataString(Environment.GetEnvironmentVariable("TESTCREDENTIAL_BOX_AUTHID")!)}", + + }.Concat(Parameters.GlobalTestParameters).ToArray()); + + if (exitCode != 0) Assert.Fail("BackendTester is returning non-zero exit code, check logs for details"); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/LiveTests/Duplicati.Backend.Tests/CIFS/CIFSTests.cs b/LiveTests/Duplicati.Backend.Tests/CIFS/CIFSTests.cs index cedf8ac0b..d7547f52b 100644 --- a/LiveTests/Duplicati.Backend.Tests/CIFS/CIFSTests.cs +++ b/LiveTests/Duplicati.Backend.Tests/CIFS/CIFSTests.cs @@ -19,6 +19,8 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +using DotNet.Testcontainers.Images; + namespace Duplicati.Backend.Tests.CIFS; /// @@ -71,6 +73,7 @@ smbd --foreground --no-process-group --debug-stdout"; var container = new ContainerBuilder() .WithImage("ubuntu:22.04") + .WithImagePullPolicy(PullPolicy.Missing) .WithCommand("/bin/bash", "-c", "apt-get update && " + "DEBIAN_FRONTEND=noninteractive apt-get install -y samba && " + "bash /etc/samba/entrypoint.sh") diff --git a/LiveTests/Duplicati.Backend.Tests/README.md b/LiveTests/Duplicati.Backend.Tests/README.md index d1d9561eb..c15ba6fcb 100644 --- a/LiveTests/Duplicati.Backend.Tests/README.md +++ b/LiveTests/Duplicati.Backend.Tests/README.md @@ -12,6 +12,39 @@ MAX_FILE_SIZE default is 1000kb NUMBER_OF_FILES default is 20 ``` +## Backends CI Status + +| Backend | CI Planned | CI Status | +|----------|------------|-----------| +| pCloud | Planned | Live | +| WebDav | Planned | Live | +| Shhv2 | Planned | Live | +| S3 | Planned | Live | +| Ftp | Planned | Live | +| Planned | Planned | Live | +| Dropbox | Planned | Live | +| AlternativeFTP | Planned | Live | +| AzureBlob | Planned | Live | +| CIFS | Planned | Live | +| Google Drive | Planned | Live | +| Box | Planned | Live | +| TahoeLAFS | Pending Decision | | +| TescentCOS | Pending Decision | | +| Storj | Pending Decision | | +| Sia | Pending Decision | | +| SharePoint | Pending Decision | | +| Rclone | Pending Decision | | +| OneDrive | Pending Decision | | +| OpenStack | Pending Decision | | +| Mega | Pending Decision | | +| Jottacloud | Pending Decision | | +| IDrive2 | Planned | | +| File | Planned | | +| CloudFiles | Pending Decision | | +| backBlaze | Planned | Live | +| AliyunOSS | Pending Decision | | + + ## Backends that do not require Environment variables * FTP _(TestContainers required)_ @@ -23,6 +56,24 @@ Please note that TestContainers token has to be configured in secrets/Github act ## Backends that require Environment variables +## Box.com + +``` +TESTCREDENTIAL_BOX_FOLDER +TESTCREDENTIAL_BOX_AUTHID +``` + +## Backblaze B2 + +Backblaze B2 credentials are mapped to the following environment variables: + +``` +TESTCREDENTIAL_B2_BUCKET +TESTCREDENTIAL_B2_FOLDER +TESTCREDENTIAL_B2_USERNAME +TESTCREDENTIAL_B2_PASSWORD +``` + ## Google Drive: Google Drive credentials are mapped to the following environment variables: @@ -31,6 +82,7 @@ Google Drive credentials are mapped to the following environment variables: TESTCREDENTIAL_GOOGLEDRIVE_FOLDER TESTCREDENTIAL_GOOGLEDRIVE_TOKEN ``` + ## S3 S3 credentials are mapped to the following environment variables: diff --git a/README.ja-JP.md b/README.ja-JP.md index 2f21bc824..1d24d280c 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -19,87 +19,81 @@ Gitterは削除済 [![カバレッジの状況](https://coveralls.io/repos/github/duplicati/duplicati/badge.svg?branch=HEAD)](https://coveralls.io/github/duplicati/duplicati?branch=HEAD) [![ライセンス](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/duplicati/duplicati/blob/master/LICENSE.txt) +Duplicati は、フリー(自由)でオープンソースのバックアップ用クライアントです。圧縮し、暗号化した増分バックアップを、クラウドストレージサービスや遠隔のファイルサーバー上に安全に保存できます。Duplicati は、主に以下のサービスやソフトウェアで使うことができます。 -Duplicatiは、フリー(自由)でオープンソースのバックアップ用クライアントです。圧縮し、暗号化した増分バックアップを、クラウドストレージサービスや遠隔のファイルサーバー上に安全に保存できます。Duplicatiは、主に以下のサービスやソフトウェアで使うことができます。 +   _Amazon S3、[IDrive e2](https://www.idrive.com/e2/duplicati "Using Duplicati with IDrive e2")、[Backblaze (B2)](https://www.backblaze.com/blog/duplicati-backups-cloud-storage/ "Duplicati with Backblaze B2 Cloud Storage")、Box、Dropbox、FTP、Google クラウド、Google ドライブ、MEGA、Microsoft Azure、Microsoft OneDrive、Rackspace Cloud Files、OpenStack Storage (Swift)、Sia、Storj DCS、SSH (SFTP)、WebDAV、Tencent クラウドオブジェクトストレージ(COS)、Aliyun OSS、[その他にも対応しています!](https://docs.duplicati.com/backup-destinations/destination-overview)_ -   *Amazon S3、[IDrive e2](https://www.idrive.com/e2/duplicati "Using Duplicati with IDrive e2")、[Backblaze (B2)](https://www.backblaze.com/blog/duplicati-backups-cloud-storage/ "Duplicati with Backblaze B2 Cloud Storage")、Box、Dropbox、FTP、Googleクラウド、Googleドライブ、MEGA、Microsoft Azure、Microsoft OneDrive、Rackspace Cloud Files、OpenStack Storage (Swift)、Sia、Storj DCS、SSH (SFTP)、WebDAV、Tencentクラウドオブジェクトストレージ(COS)、Aliyun OSS、[その他にも対応しています!](https://docs.duplicati.com/backup-destinations/destination-overview)* +Duplicati は MIT ライセンスで公開されており、Windows、OSX、Linux で利用できます(.NET 4.7.1 以上、または Mono 5.10.0 以上が必要です)。 -DuplicatiはMITライセンスで公開されており、Windows、OSX、Linuxで利用できます(.NET 4.7.1以上、またはMono 5.10.0以上が必要です)。 +# ダウンロード -ダウンロード -======== +Duplicati 2.0 のベータ版が Duplicati の最新バージョンとなります。 -Duplicati 2.0のベータ版がDuplicatiの最新バージョンとなります。 +[ここをクリックすると、Duplicati 2.0 のベータ版をダウンロードできます。](https://duplicati.com/download) -[ここをクリックすると、Duplicati 2.0のベータ版をダウンロードできます。](https://duplicati.com/download) - -ベータ版では、アップデートがある場合に自動的に通知を行い、1クリック(またはターミナルでのコマンド入力)でアップグレードできます。 +ベータ版では、アップデートがある場合に自動的に通知を行い、1 クリック(またはターミナルでのコマンド入力)でアップグレードできます。 より新しい[テスト版に関しては、最新のリリースを確認](https://github.com/duplicati/duplicati/releases)するか、ソフトウェア上の画面またはコマンドラインで、別のアップデートチャンネルを選択してください。 -全てのリリースは、GPGで署名されます。署名に使われる公開鍵は[3DAC703D](https://keys.openpgp.org/search?q=0xC20E90473DAC703D)となります。最新の署名ファイル(バイナリー版とASCII版)については、[Duplicatiのダウンロード用ページ](https://github.com/duplicati/duplicati/releases)から入手できます。 +全てのリリースは、GPG で署名されます。署名に使われる公開鍵は[3DAC703D](https://keys.openpgp.org/search?q=0xC20E90473DAC703D)となります。最新の署名ファイル(バイナリー版と ASCII 版)については、[Duplicati のダウンロード用ページ](https://github.com/duplicati/duplicati/releases)から入手できます。 -サポート -======= +# サポート -Duplicatiは、活発なコミュニティーによってサポートが行われています。コミュニティーには[フォーラム](https://forum.duplicati.com)からご参加ください。 +Duplicati は、活発なコミュニティーによってサポートが行われています。コミュニティーには[フォーラム](https://forum.duplicati.com)からご参加ください。 -[Duplicatiのマニュアル](https://docs.duplicati.com)もあります。マニュアルの作成や維持にぜひ[ご参加](https://github.com/kees-z/DuplicatiDocs)ください。 +[Duplicati のマニュアル](https://docs.duplicati.com)もあります。マニュアルの作成や維持にぜひ[ご参加](https://github.com/kees-z/DuplicatiDocs)ください。 -機能 -======== +# 機能 - * 全てのデータについて、アップロードする前にAES-256(またはGNU Privacy Guard)による暗号化を行い、データの安全性を確保します。 - * 最初に全体のフルバックアップを行い、その後、小さな増分のバックアップを送信することにより、回線の帯域幅と保存領域の使用量を節約します。 - * スケジュールの設定機能により、バックアップを最新のものに自動的に維持します。 - * 新しいリリースが公開された際に、通知を行います。 - * 暗号化したバックアップのファイルを、FTP、 Cloudfiles、WebDAV、SSH (SFTP)、Amazon S3などのサービスに送信します。 - * フォルダー、ドキュメントや画像などファイルの種類、ユーザー定義のフィルターを指定して、バックアップを実行できます。 - * 簡単に使える操作画面と、コマンドラインのツールを備えています。 - * Windowsのボリュームシャドウコピーサービス(VSS)や、Linuxの論理ボリューム管理(LVM)によって、プログラムによって開かれているファイルや、ロックされているファイルを適切にバックアップできます。これにより、Microsoft Outlookを使っている際に、OutlookのPSTファイルをバックアップできます。 - * フィルター、削除に関するルール、転送や帯域幅に関する設定などを行えます。 +- 全てのデータについて、アップロードする前に AES-256(または GNU Privacy Guard)による暗号化を行い、データの安全性を確保します。 +- 最初に全体のフルバックアップを行い、その後、小さな増分のバックアップを送信することにより、回線の帯域幅と保存領域の使用量を節約します。 +- スケジュールの設定機能により、バックアップを最新のものに自動的に維持します。 +- 新しいリリースが公開された際に、通知を行います。 +- 暗号化したバックアップのファイルを、FTP、 Cloudfiles、WebDAV、SSH (SFTP)、Amazon S3 などのサービスに送信します。 +- フォルダー、ドキュメントや画像などファイルの種類、ユーザー定義のフィルターを指定して、バックアップを実行できます。 +- 簡単に使える操作画面と、コマンドラインのツールを備えています。 +- Windows のボリュームシャドウコピーサービス(VSS)や、Linux の論理ボリューム管理(LVM)によって、プログラムによって開かれているファイルや、ロックされているファイルを適切にバックアップできます。これにより、Microsoft Outlook を使っている際に、Outlook の PST ファイルをバックアップできます。 +- フィルター、削除に関するルール、転送や帯域幅に関する設定などを行えます。 -Duplicatiの利点 -================== +# Duplicati の利点 データを安全に保つこと。離れたところに保管すること。バックアップを定期的に更新すること。 とてもシンプルなルールですが、今日の多くのバックアップ用サービスやソフトウェアは、これを達成していません。 -一方、Duplicatiでは、このルールを実践しています! +一方、Duplicati では、このルールを実践しています! -データを安全に保ちましょう! 悪意をもったインターネット上の人々は、興味を引くデータをあらゆるところで探し回っているようです。しかしユーザーは、自らのプライベートなデータが第三者に暴かれてもよいとは誰も思っていません。Duplicatiでは、強力な暗号を使うことで、あなたのデータが、自分以外には全く意味不明なものになっていることを保証します。よく検討されたパスワードを使うと、あなたのバックアップファイルは、公開されているウェブサーバー上に保管されている場合でも、あなたの自宅にあり、しかし暗号化されずに保管されているファイルと比べて、より安全なものとなります。 +データを安全に保ちましょう! 悪意をもったインターネット上の人々は、興味を引くデータをあらゆるところで探し回っているようです。しかしユーザーは、自らのプライベートなデータが第三者に暴かれてもよいとは誰も思っていません。Duplicati では、強力な暗号を使うことで、あなたのデータが、自分以外には全く意味不明なものになっていることを保証します。よく検討されたパスワードを使うと、あなたのバックアップファイルは、公開されているウェブサーバー上に保管されている場合でも、あなたの自宅にあり、しかし暗号化されずに保管されているファイルと比べて、より安全なものとなります。 -バックアップは、離れたところに保管しましょう! たとえバックアップが完璧だったとしても、それがバックアップ元のデータもろとも失われてしまっては何の意味もありません。職場で火事があった場合を想像してみてください。… バックアップは火事にも負けず生き残りますか? Duplicatiはバックアップを多様な遠隔のファイルサーバーに保存し、データの更新が必要な部分だけが転送されるよう、増分バックアップをサポートしています。これによって、バックアップ元のデータから遠く離れたところにバックアップを保管しやすくなっています。 +バックアップは、離れたところに保管しましょう! たとえバックアップが完璧だったとしても、それがバックアップ元のデータもろとも失われてしまっては何の意味もありません。職場で火事があった場合を想像してみてください。… バックアップは火事にも負けず生き残りますか?  Duplicati はバックアップを多様な遠隔のファイルサーバーに保存し、データの更新が必要な部分だけが転送されるよう、増分バックアップをサポートしています。これによって、バックアップ元のデータから遠く離れたところにバックアップを保管しやすくなっています。 -定期的にバックアップを行いましょう! 最悪のケースは、しかるべきときにバックアップを行うことをうっかり忘れていたために、バックアップが古くなってしまっていることです。Duplicatiにはスケジュールの設定機能が備わっているので、簡単に、最新の状態のバックアップを定期的に作成できます。また、Duplicatiはファイルの圧縮を実行し、増分バックアップを行えるため、保存領域と帯域幅を節約できます。 +定期的にバックアップを行いましょう! 最悪のケースは、しかるべきときにバックアップを行うことをうっかり忘れていたために、バックアップが古くなってしまっていることです。Duplicati にはスケジュールの設定機能が備わっているので、簡単に、最新の状態のバックアップを定期的に作成できます。また、Duplicati はファイルの圧縮を実行し、増分バックアップを行えるため、保存領域と帯域幅を節約できます。 -開発に参加 -================== +# 開発に参加 ## 不具合を報告 -バグの管理にはGitHubを使っています。不具合を発見した場合は https://github.com/duplicati/duplicati/issues で既存のIssueがないか検索して、もしまだ報告されていないようであれば、新しいIssueを作成してください。 + +バグの管理には GitHub を使っています。不具合を発見した場合は https://github.com/duplicati/duplicati/issues で既存の Issue がないか検索して、もしまだ報告されていないようであれば、新しい Issue を作成してください。 ## 翻訳に参加 -Duplicatiの翻訳に興味がある場合は、[Transifex](https://www.transifex.com/duplicati/duplicati/dashboard/)で翻訳作業にご参加ください。 + +Duplicati の翻訳に興味がある場合は、[Transifex](https://www.transifex.com/duplicati/duplicati/dashboard/)で翻訳作業にご参加ください。 ## 開発作業に参加 -開発環境を設定してDuplicatiをビルドする方法については、[ウィキ](https://github.com/duplicati/duplicati/wiki/How-to-build-from-source)をご覧ください。不具合を修正したり、Duplicatiを改善したりするプルリクエストについては、いつでも歓迎します。 -修正すべき問題を探している場合は、[minor change](https://github.com/duplicati/duplicati/issues?q=is%3Aissue+is%3Aopen+label%3A%22minor+change%22)のIssueを確認してみてください。ウェブUIの開発に慣れている場合は、 [「UI」でタグ付けされたIssue](https://github.com/duplicati/duplicati/issues?q=is%3Aissue+is%3Aopen+label%3A%22UI%22)を見てみてください。 +開発環境を設定して Duplicati をビルドする方法については、[ウィキ](https://docs.duplicati.com/installation-details/developer)をご覧ください。不具合を修正したり、Duplicati を改善したりするプルリクエストについては、いつでも歓迎します。 +修正すべき問題を探している場合は、[minor change](https://github.com/duplicati/duplicati/issues?q=is%3Aissue+is%3Aopen+label%3A%22minor+change%22)の Issue を確認してみてください。ウェブ UI の開発に慣れている場合は、 [「UI」でタグ付けされた Issue](https://github.com/duplicati/duplicati/issues?q=is%3Aissue+is%3Aopen+label%3A%22UI%22)を見てみてください。 貢献していただいた皆様に感謝いたします! - ## 後援 後援いただいている皆様に感謝いたします!🙏 - ## スポンサー -以下に、Duplicatiに寄付していただいたスポンサーを一覧でご紹介します。 +以下に、Duplicati に寄付していただいたスポンサーを一覧でご紹介します。 diff --git a/README.md b/README.md index 94240146a..93d4d54be 100644 --- a/README.md +++ b/README.md @@ -20,15 +20,13 @@ Removed Gitter [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/duplicati/duplicati/blob/master/LICENSE.txt) [![Gurubase](https://img.shields.io/badge/Gurubase-Ask%20Duplicati%20Guru-006BFF)](https://gurubase.io/g/duplicati) - Duplicati is a free, open source, backup client that securely stores encrypted, incremental, compressed backups on cloud storage services and remote file servers. It works with: -   *Amazon S3, [IDrive e2](https://www.idrive.com/e2/duplicati "Using Duplicati with IDrive e2"), [Backblaze (B2)](https://www.backblaze.com/blog/duplicati-backups-cloud-storage/ "Duplicati with Backblaze B2 Cloud Storage"), Box, Dropbox, FTP, Google Cloud and Drive, MEGA, Microsoft Azure and OneDrive, Rackspace Cloud Files, OpenStack Storage (Swift), Sia, Storj DCS, SSH (SFTP), WebDAV, Tencent Cloud Object Storage (COS), Aliyun OSS, [and more!](https://docs.duplicati.com/backup-destinations/destination-overview)* +   _Amazon S3, [IDrive e2](https://www.idrive.com/e2/duplicati "Using Duplicati with IDrive e2"), [Backblaze (B2)](https://www.backblaze.com/blog/duplicati-backups-cloud-storage/ "Duplicati with Backblaze B2 Cloud Storage"), Box, Dropbox, FTP, Google Cloud and Drive, MEGA, Microsoft Azure and OneDrive, Rackspace Cloud Files, OpenStack Storage (Swift), Sia, Storj DCS, SSH (SFTP), WebDAV, Tencent Cloud Object Storage (COS), Aliyun OSS, [and more!](https://docs.duplicati.com/backup-destinations/destination-overview)_ Duplicati is licensed under the MIT license and available for Windows, OSX and Linux (.NET 4.7.1+ or Mono 5.10.0+ required). -Download -======== +# Download The latest version of Duplicati is a beta version for the Duplicati 2.0 release. @@ -39,28 +37,25 @@ For even more [bleeding edge access, check the latest releases](https://github.c All releases are GPG signed with the public key [3DAC703D](https://keys.openpgp.org/search?q=0xC20E90473DAC703D). The latest signature file and latest ASCII signature file are also available from [the Duplicati download page](https://github.com/duplicati/duplicati/releases). -Support -======= +# Support Duplicati is supported by an [active community and you can reach them via our forum](https://forum.duplicati.com). We have a great [Duplicati manual](https://docs.duplicati.com) that you can also [contribute to](https://github.com/kees-z/DuplicatiDocs). -Features -======== +# Features - * Duplicati uses AES-256 encryption (or GNU Privacy Guard) to secure all data before it is uploaded. - * Duplicati uploads a full backup initially and stores smaller, incremental updates afterwards to save bandwidth and storage space. - * A scheduler keeps backups up-to-date automatically. - * Integrated updater notifies you when a new release is out - * Encrypted backup files are transferred to targets like FTP, Cloudfiles, WebDAV, SSH (SFTP), Amazon S3 and others. - * Duplicati allows backups of folders, document types like e.g. documents or images, or custom filter rules. - * Duplicati is available as application with an easy-to-use user interface and as command line tool. - * Duplicati can make proper backups of opened or locked files using the Volume Snapshot Service (VSS) under Windows or the Logical Volume Manager (LVM) under Linux. This allows Duplicati to back up the Microsoft Outlook PST file while Outlook is running. - * Filters, deletion rules, transfer and bandwidth options, etc +- Duplicati uses AES-256 encryption (or GNU Privacy Guard) to secure all data before it is uploaded. +- Duplicati uploads a full backup initially and stores smaller, incremental updates afterwards to save bandwidth and storage space. +- A scheduler keeps backups up-to-date automatically. +- Integrated updater notifies you when a new release is out +- Encrypted backup files are transferred to targets like FTP, Cloudfiles, WebDAV, SSH (SFTP), Amazon S3 and others. +- Duplicati allows backups of folders, document types like e.g. documents or images, or custom filter rules. +- Duplicati is available as application with an easy-to-use user interface and as command line tool. +- Duplicati can make proper backups of opened or locked files using the Volume Snapshot Service (VSS) under Windows or the Logical Volume Manager (LVM) under Linux. This allows Duplicati to back up the Microsoft Outlook PST file while Outlook is running. +- Filters, deletion rules, transfer and bandwidth options, etc -Why use Duplicati? -================== +# Why use Duplicati? Keep your data safe, store it far away, update your backup regularly! This is a simple rule but many backup solutions do not achieve that today. @@ -72,34 +67,33 @@ Store your backup far away! The best backup is useless when it is destroyed toge Backup regularly! The worst case is that your backup is outdated simply because someone forgot to make a backup at the right time. Duplicati has a built-in scheduler, so that it's easy to have a regular, up-to-date backup. Furthermore, Duplicati uses file compression and is able to store incremental backups to save storage space and bandwidth. -Contributing -================== +# Contributing ## Contributing Bug reports + We use GitHub for bug tracking. Please search the existing issues for your bug and create a new one if the issue is not yet tracked: https://github.com/duplicati/duplicati/issues ## Contributing Translations + Interested in helping to translate duplicati? Help is always welcome at transifex: https://www.transifex.com/duplicati/duplicati/dashboard/ ## Contributing Code -Instructions on how to setup your development environment and build duplicati can be found in the [wiki](https://github.com/duplicati/duplicati/wiki/How-to-build-from-source). Pull requests fixing bugs or improving duplicati in another way are appreciated. + +Instructions on how to setup your development environment and build duplicati can be found in the [documentation](https://docs.duplicati.com/installation-details/developer). Pull requests fixing bugs or improving duplicati in another way are appreciated. If you are looking for an issue to fix, try looking at one of the [minor change](https://github.com/duplicati/duplicati/issues?q=is%3Aissue+is%3Aopen+label%3A%22minor+change%22) issues. If you are most used to web development, have a look at the [issues marked UI](https://github.com/duplicati/duplicati/issues?q=is%3Aissue+is%3Aopen+label%3A%22UI%22). - Thank you to all our existing contributors: - ## Backers Thank you to all our backers! 🙏 - ## Sponsors The list below reflects the sponsors who donated to the open-source project. diff --git a/README.zh-CN.md b/README.zh-CN.md index 9c81224bb..d7aa179c0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -23,15 +23,13 @@ [![覆盖率状态](https://coveralls.io/repos/github/duplicati/duplicati/badge.svg?branch=HEAD)](https://coveralls.io/github/duplicati/duplicati?branch=HEAD) [![许可](https://img.shields.io/github/license/duplicati/duplicati.svg)](https://github.com/duplicati/duplicati/blob/master/LICENSE.txt) - Duplicati 是一个免费、开源的备份客户端,可以安全地将加密、增量、压缩的备份存储在云存储服务和远程文件服务器上。它与以下服务兼容: -   *亚马逊 S3、[IDrive e2](https://www.idrive.com/e2/duplicati "使用 Duplicati 与 IDrive e2")、[Backblaze (B2)](https://www.backblaze.com/blog/duplicati-backups-cloud-storage/ "Duplicati 与 Backblaze B2 云存储")、Box、Dropbox、FTP、Google Cloud 和 Drive、MEGA、Microsoft Azure 和 OneDrive、Rackspace Cloud Files、OpenStack Storage (Swift)、Sia、Storj DCS、SSH (SFTP)、WebDAV、阿里云对象存储(OSS)、百度网盘、阿里云盘、腾讯云对象存储 (COS)、[以及更多!](https://docs.duplicati.com/backup-destinations/destination-overview)* +   _亚马逊 S3、[IDrive e2](https://www.idrive.com/e2/duplicati "使用 Duplicati 与 IDrive e2")、[Backblaze (B2)](https://www.backblaze.com/blog/duplicati-backups-cloud-storage/ "Duplicati 与 Backblaze B2 云存储")、Box、Dropbox、FTP、Google Cloud 和 Drive、MEGA、Microsoft Azure 和 OneDrive、Rackspace Cloud Files、OpenStack Storage (Swift)、Sia、Storj DCS、SSH (SFTP)、WebDAV、阿里云对象存储(OSS)、百度网盘、阿里云盘、腾讯云对象存储 (COS)、[以及更多!](https://docs.duplicati.com/backup-destinations/destination-overview)_ Duplicati 根据 LGPL 许可证授权,并可用于 Windows、OSX 和 Linux (.NET 4.7.1+ 或 Mono 5.10.0+ 需要)。 -下载 -======== +# 下载 Duplicati 的最新版本是 Duplicati 2.0 发布的测试版。 @@ -42,28 +40,25 @@ Duplicati 的最新版本是 Duplicati 2.0 发布的测试版。 所有发布版本都使用公钥 [3DAC703D](https://keys.openpgp.org/search?q=0xC20E90473DAC703D) 进行 GPG 签名。最新的签名文件和最新的 ASCII 签名文件也可以在 [Duplicati 下载页面](https://github.com/duplicati/duplicati/releases) 获取。 -支持 -======= +# 支持 Duplicati 由一个[活跃的社区支持,您可以通过我们的论坛与他们联系](https://forum.duplicati.com)。 我们有一个很棒的 [Duplicati 手册](https://docs.duplicati.com),您也可以[为其做出贡献](https://github.com/kees-z/DuplicatiDocs)。 -功能 -======== +# 功能 - * Duplicati 使用 AES-256 加密(或 GNU Privacy Guard)在上传之前加密所有数据。 - * Duplicati 最初上传完整备份,之后存储较小的增量更新,以节省带宽和存储空间。 - * 计划程序自动保持备份的最新状态。 - * 集成的更新器会通知您新版本发布 - * 加密的备份文件被传输到像 FTP、Cloudfiles、WebDAV、SSH (SFTP)、Amazon S3 等目标。 - * Duplicati 允许备份文件夹、文档类型(如文档或图像)或自定义过滤规则。 - * Duplicati 有易于使用的用户界面和命令行工具。 - * Duplicati 可以使用 Windows 下的卷快照服务 (VSS) 或 Linux 下的逻辑卷管理器 (LVM) 对打开或锁定的文件进行正确备份。这允许 Duplicati 在 Outlook 运行时备份 Microsoft Outlook PST 文件。 - * 过滤器、删除规则、传输和带宽选项等 +- Duplicati 使用 AES-256 加密(或 GNU Privacy Guard)在上传之前加密所有数据。 +- Duplicati 最初上传完整备份,之后存储较小的增量更新,以节省带宽和存储空间。 +- 计划程序自动保持备份的最新状态。 +- 集成的更新器会通知您新版本发布 +- 加密的备份文件被传输到像 FTP、Cloudfiles、WebDAV、SSH (SFTP)、Amazon S3 等目标。 +- Duplicati 允许备份文件夹、文档类型(如文档或图像)或自定义过滤规则。 +- Duplicati 有易于使用的用户界面和命令行工具。 +- Duplicati 可以使用 Windows 下的卷快照服务 (VSS) 或 Linux 下的逻辑卷管理器 (LVM) 对打开或锁定的文件进行正确备份。这允许 Duplicati 在 Outlook 运行时备份 Microsoft Outlook PST 文件。 +- 过滤器、删除规则、传输和带宽选项等 -为什么使用 Duplicati? -================== +# 为什么使用 Duplicati? 保护您的数据安全,将其存储在远处,定期更新您的备份! 这是一个简单的规则,但许多备份解决方案今天都无法做到。 @@ -75,33 +70,33 @@ Duplicati 由一个[活跃的社区支持,您可以通过我们的论坛与他 定期备份!最糟糕的情况是,您的备份过时了,仅仅是因为有人忘了在正确的时间备份。Duplicati 有内置的计划程序,因此很容易拥有定期、最新的备份。此外,Duplicati 使用文件压缩,并能够存储增量备份以节省存储空间和带宽。 -贡献 -================== +# 贡献 ## 贡献错误报告 + 我们使用 GitHub 进行错误跟踪。请在创建新问题前先搜索已有的问题,看看您的错误是否已被记录: https://github.com/duplicati/duplicati/issues ## 贡献翻译 + 对帮助翻译 duplicati 感兴趣吗?欢迎在 transifex 提供帮助: https://www.transifex.com/duplicati/duplicati/dashboard/ ## 贡献代码 -关于如何设置您的开发环境以及如何构建 duplicati 的说明可以在 [wiki](https://github.com/duplicati/duplicati/wiki/How-to-build-from-source) 中找到。我们欣赏任何修复错误或以其他方式改进 duplicati 的拉取请求。 + +关于如何设置您的开发环境以及如何构建 duplicati 的说明可以在 [docs](https://docs.duplicati.com/installation-details/developer) 中找到。我们欣赏任何修复错误或以其他方式改进 duplicati 的拉取请求。 如果您正在寻找一个问题来解决,请尝试查看其中一个标记为 [小改动](https://github.com/duplicati/duplicati/issues?q=is%3Aissue+is%3Aopen+label%3A%22minor+change%22) 的问题。如果您最熟悉的是 Web 开发,请查看标记为 [UI](https://github.com/duplicati/duplicati/issues?q=is%3Aissue+is%3Aopen+label%3A%22UI%22) 的问题。 感谢我们所有的现有贡献者: - ## 赞助者 感谢所有的赞助者!🙏 [[成为赞助者](https://opencollective.com/duplicati#backer)] - ## 赞助商 通过成为赞助商来支持这个项目。您的标志将会出现在这里,附带链接到您的网站。[[成为赞助商](https://opencollective.com/duplicati#sponsor)] diff --git a/ReleaseBuilder/Build/Command.Compile.Post.cs b/ReleaseBuilder/Build/Command.Compile.Post.cs index 122576901..0be144823 100644 --- a/ReleaseBuilder/Build/Command.Compile.Post.cs +++ b/ReleaseBuilder/Build/Command.Compile.Post.cs @@ -64,6 +64,39 @@ public static partial class Command } } + /// + /// Verify that some files that are expected to be present in the target directory are there + /// + /// The build directory to verify + /// The target to verify for + /// An awaitable task + public static Task VerifyTargetDirectory(string buildDir, PackageTarget target) + { + var rootFiles = Directory.EnumerateFiles(buildDir, "*", SearchOption.TopDirectoryOnly) + .Where(x => x.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || x.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + .Select(x => Path.GetFileName(x)) + .ToHashSet(Duplicati.Library.Utility.Utility.ClientFilenameStringComparer); + + // Random sample of files we expect + var probeFiles = new string[] { + "System.CommandLine.dll", + "System.CommandLine.NamingConventionBinder.dll", + "AWSSDK.S3.dll", + "CoCoL.dll", + "Duplicati.Library.Interface.dll", + "Google.Apis.Auth.dll", + "Google.Apis.Core.dll", + "SQLiteHelper.dll", + "SQLite.Interop.dll", + }; + + foreach (var f in probeFiles) + if (!rootFiles.Contains(f)) + throw new Exception($"Expected file {f} for {target.BuildTargetString}, but was not found in build directory {buildDir}"); + + return Task.CompletedTask; + } + /// /// Set of files that are unwanted despite the OS /// @@ -248,14 +281,7 @@ public static partial class Command var licenseTarget = Path.Combine(tmpApp, "Contents", "Licenses"); Directory.Move(Path.Combine(binDir, "licenses"), licenseTarget); - // Apply code signing, if requested - if (rtcfg.UseCodeSignSigning) - { - var entitlementFile = Path.Combine(resourcesDir, "Entitlements.plist"); - await PackageSupport.SignMacOSBinaries(rtcfg, binDir, entitlementFile); - await rtcfg.Codesign(tmpApp, entitlementFile); - } - + // Make files executable if (!OperatingSystem.IsWindows()) foreach (var f in Directory.EnumerateFiles(binDir, "*.launchagent.plist", SearchOption.TopDirectoryOnly)) File.SetUnixFileMode(f, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead); @@ -316,7 +342,7 @@ public static partial class Command /// An awaitable task static async Task SignWindowsExecutables(string buildDir, RuntimeConfig rtcfg) { - var cfg = Program.Configuration; + var cfg = rtcfg.Configuration; if (!rtcfg.UseAuthenticodeSigning) return; diff --git a/ReleaseBuilder/Build/Command.Compile.cs b/ReleaseBuilder/Build/Command.Compile.cs index 9851b45db..90ee18ce9 100644 --- a/ReleaseBuilder/Build/Command.Compile.cs +++ b/ReleaseBuilder/Build/Command.Compile.cs @@ -78,6 +78,11 @@ public static partial class Command // TODO: Self contained builds are bloating the build size // Alternative is to require the .NET runtime to be installed + // Build into isolated folder + var buildfolder = Path.Combine(buildDir, target.BuildTargetString + "-out"); + if (Directory.Exists(buildfolder)) + Directory.Delete(buildfolder, true); + // Fix any RIDs that differ from .NET SDK var archstring = target.Arch switch { @@ -88,7 +93,7 @@ public static partial class Command var command = new string[] { "dotnet", "publish", proj, "-c", "Release", - "-o", tmpfolder, + "-o", buildfolder, "-r", archstring, $"/p:AssemblyVersion={releaseInfo.Version}", $"/p:Version={releaseInfo.Version}-{releaseInfo.Channel}-{releaseInfo.Timestamp:yyyyMMdd}", @@ -104,11 +109,17 @@ public static partial class Command Console.WriteLine($"Error building {proj} for {target.BuildTargetString}: {ex.Message}"); throw; } + + // Merge the build into the temporary target folder + EnvHelper.CopyDirectory(buildfolder, tmpfolder, true); + Directory.Delete(buildfolder, true); } // Perform any post-build steps, cleaning and signing as needed await PostCompile.PrepareTargetDirectory(baseDir, tmpfolder, target, rtcfg, keepBuilds); + await PostCompile.VerifyTargetDirectory(tmpfolder, target); + // Move the final build to the output folder Directory.Move(tmpfolder, outputFolder); } diff --git a/ReleaseBuilder/Build/Command.CreatePackage.cs b/ReleaseBuilder/Build/Command.CreatePackage.cs index e7ef0f9ba..21232ab44 100644 --- a/ReleaseBuilder/Build/Command.CreatePackage.cs +++ b/ReleaseBuilder/Build/Command.CreatePackage.cs @@ -1,22 +1,22 @@ // Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the +// +// 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 +// +// 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 +// +// 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.Globalization; using System.IO.Compression; @@ -175,7 +175,7 @@ public static partial class Command { // # Notarize and staple takes a while... Console.WriteLine($"Performing notarize and staple of {packageFile} ..."); - await ProcessHelper.Execute(["xcrun", "notarytool", "submit", tempFile, "--keychain-profile", Program.Configuration.ConfigFiles.NotarizeProfile, "--wait"]); + await ProcessHelper.Execute(["xcrun", "notarytool", "submit", tempFile, "--keychain-profile", rtcfg.Configuration.ConfigFiles.NotarizeProfile, "--wait"]); await ProcessHelper.Execute(["xcrun", "stapler", "staple", tempFile]); } @@ -264,6 +264,10 @@ public static partial class Command /// A task representing the asynchronous operation. static async Task BuildMsiPackage(string baseDir, string buildRoot, string msiFile, PackageTarget target, RuntimeConfig rtcfg) { + var isWindows = OperatingSystem.IsWindows(); + const string originalNamespace = "http://schemas.microsoft.com/wix/2006/wi"; + const string wixv4Namespace = "http://wixtoolset.org/schemas/v4/wxs"; + var resourcesDir = Path.Combine(baseDir, "ReleaseBuilder", "Resources", "Windows"); var resourcesSubDir = Path.Combine(resourcesDir, target.Interface switch @@ -288,7 +292,11 @@ public static partial class Command if (File.Exists(binFiles)) File.Delete(binFiles); - File.WriteAllText(binFiles, WixHeatBuilder.CreateWixFilelist(sourceFiles, version: rtcfg.ReleaseInfo.Version.ToString())); + File.WriteAllText(binFiles, WixHeatBuilder.CreateWixFilelist( + sourceFiles, + version: rtcfg.ReleaseInfo.Version.ToString(), + wixNs: isWindows ? wixv4Namespace : originalNamespace + )); var msiArch = target.Arch switch { @@ -298,22 +306,47 @@ public static partial class Command _ => throw new Exception($"Architeture not supported: {target.ArchString}") }; - List wixArgs = [ - Program.Configuration.Commands.Wix!, - "--define", $"HarvestPath={sourceFiles}", - "--arch", msiArch, - "--output", msiFile, - Path.Combine(resourcesSubDir, "Shortcuts.wxs"), - binFiles, - Path.Combine(resourcesSubDir, "Duplicati.wxs") - ]; + // Prepare the Wix arguments, different for WiX (Windows) and wixl (Linux/MacOS) + string[] wixArgs; - // Add wixl specific extensions - if (!OperatingSystem.IsWindows()) - wixArgs.InsertRange(1, [ + if (isWindows) + { + // Update namespace in the .wxs files for WiX v4 + var shortcutsTempfile = Path.Combine(buildTmp, "Shortcuts.wxs"); + var entryTempfile = Path.Combine(buildTmp, "Duplicati.wxs"); + + File.WriteAllText(shortcutsTempfile, File.ReadAllText(Path.Combine(resourcesSubDir, "Shortcuts.wxs")) + .Replace(originalNamespace, wixv4Namespace)); + + File.WriteAllText(entryTempfile, File.ReadAllText(Path.Combine(resourcesSubDir, "Duplicati.wxs")) + .Replace(originalNamespace, wixv4Namespace)); + + wixArgs = [ + rtcfg.Configuration.Commands.Wix!, + "build", + "-define", $"HarvestPath={sourceFiles}", + "-arch", msiArch, + "-out", msiFile, + shortcutsTempfile, + binFiles, + entryTempfile + ]; + } + else + { + // wixl needs the UI extension + wixArgs = [ + rtcfg.Configuration.Commands.Wix!, "--ext", "ui", "--extdir", Path.Combine(resourcesDir, "WixUIExtension"), - ]); + "--define", $"HarvestPath={sourceFiles}", + "--arch", msiArch, + "--output", msiFile, + Path.Combine(resourcesSubDir, "Shortcuts.wxs"), + binFiles, + Path.Combine(resourcesSubDir, "Duplicati.wxs") + ]; + } await ProcessHelper.Execute(wixArgs, workingDirectory: buildRoot); @@ -331,7 +364,7 @@ public static partial class Command /// The package target to create the file for /// The runtime config /// An awaitable task - static async Task PrepareAndReSignAppBundle(string appFolder, string installerDir, PackageTarget target, RuntimeConfig rtcfg) + static async Task PrepareAndSignAppBundle(string appFolder, string installerDir, PackageTarget target, RuntimeConfig rtcfg) { await PackageSupport.InstallPackageIdentifier(Path.Combine(appFolder, "Contents", "MacOS"), target); @@ -339,15 +372,10 @@ public static partial class Command if (rtcfg.UseCodeSignSigning) { var entitlementFile = Path.Combine(installerDir, "Entitlements.plist"); - var updates = new[] { Path.Combine(appFolder, "Contents", "MacOS", "package_type_id.txt") } - .Concat( - ExecutableRenames.Values.Select(x => Path.Combine(appFolder, "Contents", "MacOS", x)) - .Where(File.Exists) - ) - .Append(appFolder); - - foreach (var x in updates) - await rtcfg.Codesign(x, entitlementFile); + // In principle, it should be enough to deep sign the bundle, but the signtool is broken + await PackageSupport.SignMacOSBinaries(rtcfg, Path.Combine(appFolder, "Contents", "MacOS"), entitlementFile); + await rtcfg.Codesign(appFolder, false, entitlementFile); + await rtcfg.VerifyCodeSign(appFolder); } } @@ -365,9 +393,8 @@ public static partial class Command var mountDir = Path.Combine(buildRoot, "mount"); if (Directory.Exists(mountDir)) { - await ProcessHelper.Execute([ - "hdiutil", "detach", mountDir, "-quiet", "-force", - ], workingDirectory: buildRoot, codeIsError: _ => false); + await ProcessHelper.Execute(["hdiutil", "detach", mountDir, "-quiet", "-force",], + workingDirectory: buildRoot, codeIsError: _ => false); Directory.Delete(mountDir, false); } @@ -400,9 +427,7 @@ public static partial class Command // Change the dmg name var dmgname = $"Duplicati {rtcfg.ReleaseInfo.ReleaseName}"; Console.WriteLine($"Setting dmg name to {dmgname}"); - await ProcessHelper.Execute([ - "diskutil", "quiet", "rename", mountDir, dmgname - ], workingDirectory: mountDir); + await ProcessHelper.Execute(["diskutil", "quiet", "rename", mountDir, dmgname], workingDirectory: mountDir); // Make the Duplicati.app structure, root folder should exist var appFolder = Path.Combine(mountDir, rtcfg.MacOSAppName); @@ -411,7 +436,7 @@ public static partial class Command // Place the prepared folder EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{rtcfg.MacOSAppName}"), appFolder, recursive: true); - await PrepareAndReSignAppBundle(appFolder, resourcesDir, target, rtcfg); + await PrepareAndSignAppBundle(appFolder, resourcesDir, target, rtcfg); // Set permissions inside DMG file if (!OperatingSystem.IsWindows()) @@ -427,8 +452,8 @@ public static partial class Command File.Delete(templateDmg); Directory.Delete(mountDir, false); - if (rtcfg.UseCodeSignSigning) - await rtcfg.Codesign(dmgFile, Path.Combine(resourcesDir, "Entitlements.plist")); + await rtcfg.Codesign(dmgFile, false, Path.Combine(resourcesDir, "Entitlements.plist")); + await rtcfg.VerifyCodeSign(dmgFile); } /// @@ -455,7 +480,7 @@ public static partial class Command // Place the prepared folder EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{rtcfg.MacOSAppName}"), appFolder, recursive: true); - await PrepareAndReSignAppBundle(appFolder, installerDir, target, rtcfg); + await PrepareAndSignAppBundle(appFolder, installerDir, target, rtcfg); // Copy the source script files var scripts = new[] { "daemon", "daemon-scripts", "app-scripts" }; @@ -629,11 +654,7 @@ public static partial class Command } // Apply code signing, if requested - if (rtcfg.UseCodeSignSigning) - { - var entitlementFile = Path.Combine(installerDir, "Entitlements.plist"); - await PackageSupport.SignMacOSBinaries(rtcfg, binFolder, entitlementFile); - } + await PackageSupport.SignMacOSBinaries(rtcfg, binFolder, Path.Combine(installerDir, "Entitlements.plist")); var payloadPkgFile = target.Interface switch { @@ -748,7 +769,7 @@ public static partial class Command { // The approach here is based on: // https://www.internalpointers.com/post/build-binary-deb-package-practical-guide - // + // // It is not the recommended way to build a package, // but since the build is from a pre-build binary, // it is easier than trying to hack debhelper. @@ -1147,11 +1168,11 @@ public static partial class Command private static async Task BuildDockerImages(string baseDir, string buildRoot, IEnumerable targets, RuntimeConfig rtcfg) { // Make sure any dangling buildx instances are removed - try { await ProcessHelper.Execute([Program.Configuration.Commands.Docker!, "buildx", "rm", "duplicati-builder"], codeIsError: _ => false, suppressStdErr: true); } + try { await ProcessHelper.Execute([rtcfg.Configuration.Commands.Docker!, "buildx", "rm", "duplicati-builder"], codeIsError: _ => false, suppressStdErr: true); } catch { } // Prepare multi-build - await ProcessHelper.Execute([Program.Configuration.Commands.Docker!, "buildx", "create", "--use", "--name", "duplicati-builder"]); + await ProcessHelper.Execute([rtcfg.Configuration.Commands.Docker!, "buildx", "create", "--use", "--name", "duplicati-builder"]); // Perform a distict build for each interface type, but keep the buildx instance foreach (var interfaceType in Enum.GetValues()) @@ -1195,7 +1216,7 @@ public static partial class Command await PackageSupport.SetPermissionFlags(tgfolder, rtcfg); } - var tags = new List { rtcfg.ReleaseInfo.Channel.ToString(), rtcfg.ReleaseInfo.Version.ToString() }; + var tags = new List { rtcfg.ReleaseInfo.Channel.ToString(), rtcfg.ReleaseInfo.Version.ToString(), $"{rtcfg.ReleaseInfo.Version}-{rtcfg.ReleaseInfo.Channel}" }; if (rtcfg.ReleaseInfo.Channel == ReleaseChannel.Stable) tags.Add("latest"); @@ -1206,7 +1227,7 @@ public static partial class Command var repo = $"{rtcfg.DockerRepo}{(interfaceType == InterfaceType.Agent ? "-agent" : "")}"; // Build the images - var args = new List { Program.Configuration.Commands.Docker!, "buildx", "build" }; + var args = new List { rtcfg.Configuration.Commands.Docker!, "buildx", "build" }; args.AddRange(tags.SelectMany(x => new[] { "-t", $"{repo}:{x.ToLowerInvariant()}" })); args.AddRange([ "--platform", string.Join(",", dockerArchs), @@ -1225,6 +1246,6 @@ public static partial class Command } // Clean up - await ProcessHelper.Execute([Program.Configuration.Commands.Docker!, "buildx", "rm", "duplicati-builder"]); + await ProcessHelper.Execute([rtcfg.Configuration.Commands.Docker!, "buildx", "rm", "duplicati-builder"]); } } diff --git a/ReleaseBuilder/Build/Command.GitPush.cs b/ReleaseBuilder/Build/Command.GitPush.cs index 7b7a1b4fd..0ccbc207c 100644 --- a/ReleaseBuilder/Build/Command.GitPush.cs +++ b/ReleaseBuilder/Build/Command.GitPush.cs @@ -39,29 +39,29 @@ public static partial class Command File.WriteAllText(Path.Combine(baseDir, "ReleaseBuilder", "build_version.txt"), releaseInfo.Version.ToString()); // Add modified files - await ProcessHelper.Execute(new[] { + await ProcessHelper.Execute([ "git", "add", "ReleaseBuilder/build_version.txt", "changelog.txt" - }, workingDirectory: baseDir); + ], workingDirectory: baseDir); // Make a commit - await ProcessHelper.Execute(new[] { + await ProcessHelper.Execute([ "git", "commit", "-m", $"Version bump to v{releaseInfo.Version}-{releaseInfo.ReleaseName}", "-m", "You can download this build from: ", "-m", $"Binaries: https://updates.duplicati.com/{releaseInfo.Channel.ToString().ToLowerInvariant()}/?version={releaseInfo.Version}", "-m", $"Signature file: https://updates.duplicati.com/{releaseInfo.Channel.ToString().ToLowerInvariant()}/duplicati-{releaseInfo.ReleaseName}.signatures.zip" - }, workingDirectory: baseDir); + ], workingDirectory: baseDir); // And tag the release - await ProcessHelper.Execute(new[] { + await ProcessHelper.Execute([ "git", "tag", $"v{releaseInfo.Version}-{releaseInfo.ReleaseName}", - }, workingDirectory: baseDir); + ], workingDirectory: baseDir); // Then push the release - await ProcessHelper.Execute(new[] { "git", "push", "-u", "origin", "head" }, workingDirectory: baseDir); - await ProcessHelper.Execute(new[] { "git", "push", "--tags" }, workingDirectory: baseDir); + await ProcessHelper.Execute(["git", "push", "-u", "origin", "head"], workingDirectory: baseDir); + await ProcessHelper.Execute(["git", "push", "--tags"], workingDirectory: baseDir); } } } diff --git a/ReleaseBuilder/Build/Command.GpgSign.cs b/ReleaseBuilder/Build/Command.GpgSign.cs index a574af037..4b203a014 100644 --- a/ReleaseBuilder/Build/Command.GpgSign.cs +++ b/ReleaseBuilder/Build/Command.GpgSign.cs @@ -53,7 +53,7 @@ public static partial class Command await ProcessHelper.Execute( [ - Program.Configuration.Commands.Gpg!, + rtcfg.Configuration.Commands.Gpg!, "--pinentry-mode", "loopback", "--passphrase-fd", "0", "--batch", "--yes", @@ -86,7 +86,7 @@ public static partial class Command static (string GpgId, string GpgPassphrase) GetGpgIdAndPassphrase(RuntimeConfig rtcfg) { using var ms = new MemoryStream(); - using var fs = File.OpenRead(Program.Configuration.ConfigFiles.GpgKeyfile); + using var fs = File.OpenRead(rtcfg.Configuration.ConfigFiles.GpgKeyfile); SharpAESCrypt.SharpAESCrypt.Decrypt(rtcfg.KeyfilePassword, fs, ms); var parts = System.Text.Encoding.UTF8.GetString(ms.ToArray()).Split('\n', 2, StringSplitOptions.RemoveEmptyEntries); return (parts[0], parts[1]); diff --git a/ReleaseBuilder/Build/Command.PackageSupport.cs b/ReleaseBuilder/Build/Command.PackageSupport.cs index aa085e1b5..39567a09b 100644 --- a/ReleaseBuilder/Build/Command.PackageSupport.cs +++ b/ReleaseBuilder/Build/Command.PackageSupport.cs @@ -115,7 +115,7 @@ public static partial class Command .ToList(); foreach (var f in signtargets) - await rtcfg.Codesign(f, entitlementFile); + await rtcfg.Codesign(f, false, entitlementFile); } } } diff --git a/ReleaseBuilder/Build/Command.RuntimeConfig.cs b/ReleaseBuilder/Build/Command.RuntimeConfig.cs index a47401608..b78a40504 100644 --- a/ReleaseBuilder/Build/Command.RuntimeConfig.cs +++ b/ReleaseBuilder/Build/Command.RuntimeConfig.cs @@ -27,24 +27,23 @@ public static partial class Command /// /// Setup of the current runtime information /// - private class RuntimeConfig + /// + /// Constructs a new + /// + /// The release info to use + /// The keyfile password to use + /// The sign keys + /// The changelog news + /// The command input + private class RuntimeConfig( + Configuration configuration, + ReleaseInfo releaseInfo, + IEnumerable signKeys, + string keyfilePassword, + string changelogNews, + CommandInput input + ) { - /// - /// Constructs a new - /// - /// The release info to use - /// The keyfile password to use - /// The sign keys - /// The changelog news - /// The command input - public RuntimeConfig(ReleaseInfo releaseInfo, IEnumerable signKeys, string keyfilePassword, string changelogNews, CommandInput input) - { - ReleaseInfo = releaseInfo; - SignKeys = signKeys; - KeyfilePassword = keyfilePassword; - ChangelogNews = changelogNews; - Input = input; - } /// /// The cached password for the pfx file @@ -54,27 +53,30 @@ public static partial class Command /// /// The commandline input /// - private CommandInput Input { get; } - + private CommandInput Input => input; + /// + /// The configuration to use + /// + public Configuration Configuration => configuration; /// /// The release info for this run /// - public ReleaseInfo ReleaseInfo { get; } + public ReleaseInfo ReleaseInfo => releaseInfo; /// /// The keyfile password for this run /// - public IEnumerable SignKeys { get; } + public IEnumerable SignKeys => signKeys; /// /// The primary password /// - public string KeyfilePassword { get; } + public string KeyfilePassword => keyfilePassword; /// /// The changelog news /// - public string ChangelogNews { get; } + public string ChangelogNews => changelogNews; /// /// Gets the PFX password and throws if not possible @@ -89,6 +91,11 @@ public static partial class Command /// private bool? _useAuthenticodeSigning; + /// + /// Cache value for checking if jsign tool should be used for authenticode signing + /// + private bool? _useJsignToolForAuthenticode; + /// /// Checks if Authenticode signing should be enabled /// @@ -102,18 +109,39 @@ public static partial class Command return; } - if (Program.Configuration.IsAuthenticodePossible()) - _useAuthenticodeSigning = true; - else + if (Configuration.IsAuthenticodePossibleWithSignTool()) { - if (ConsoleHelper.ReadInput("Configuration missing for osslsigncode, continue without signing executables?", "Y", "n") == "Y") + _useAuthenticodeSigning = true; + _useJsignToolForAuthenticode = false; + return; + } + + if (Configuration.IsAuthenticodePossibleWithJsignTool()) + { + if (string.IsNullOrWhiteSpace(Input.SignkeyPin)) { - _useAuthenticodeSigning = false; - return; + var signKeyPin = EnvHelper.GetEnvKey("SIGNKEY_PIN", ""); + if (string.IsNullOrWhiteSpace(signKeyPin)) + signKeyPin = ConsoleHelper.ReadPassword("Enter the pin for the signing key"); + input = input with { SignkeyPin = signKeyPin }; } - throw new Exception("Configuration is not set up for osslsigncode"); + if (!string.IsNullOrWhiteSpace(Input.SignkeyPin)) + { + _useAuthenticodeSigning = true; + _useJsignToolForAuthenticode = true; + return; + } } + + if (ConsoleHelper.ReadInput("Configuration missing for jsign/signtool/osslsigncode, continue without signing executables?", "Y", "n") == "Y") + { + _useAuthenticodeSigning = false; + _useJsignToolForAuthenticode = false; + return; + } + + throw new Exception("Configuration is not set up for jsign/signtool/osslsigncode"); } } @@ -137,7 +165,7 @@ public static partial class Command if (!OperatingSystem.IsMacOS()) _useCodeSignSigning = false; - else if (Program.Configuration.IsCodeSignPossible()) + else if (Configuration.IsCodeSignPossible()) _useCodeSignSigning = true; else { @@ -166,7 +194,7 @@ public static partial class Command { try { - var res = await ProcessHelper.ExecuteWithOutput([Program.Configuration.Commands.Docker!, "ps"], suppressStdErr: true); + var res = await ProcessHelper.ExecuteWithOutput([Configuration.Commands.Docker!, "ps"], suppressStdErr: true); _dockerBuild = true; } catch @@ -203,7 +231,7 @@ public static partial class Command if (!OperatingSystem.IsMacOS()) _useNotarizeSigning = false; - else if (Program.Configuration.IsNotarizePossible()) + else if (Configuration.IsNotarizePossible()) _useNotarizeSigning = true; else { @@ -236,7 +264,7 @@ public static partial class Command return; } - if (Program.Configuration.IsGpgPossible()) + if (Configuration.IsGpgPossible()) _useGpgSigning = true; else { @@ -269,7 +297,7 @@ public static partial class Command return; } - if (Program.Configuration.IsAwsUploadPossible()) + if (Configuration.IsAwsUploadPossible()) _useS3Upload = true; else { @@ -303,7 +331,7 @@ public static partial class Command return; } - if (Program.Configuration.IsGithubUploadPossible()) + if (Configuration.IsGithubUploadPossible()) _useGithubUpload = true; else { @@ -336,7 +364,7 @@ public static partial class Command return; } - if (Program.Configuration.IsUpdateServerReloadPossible()) + if (Configuration.IsUpdateServerReloadPossible()) _useUpdateServerReload = true; else { @@ -370,7 +398,7 @@ public static partial class Command return; } - if (Program.Configuration.IsDiscourseAnnouncePossible()) + if (Configuration.IsDiscourseAnnouncePossible()) _useDiscourseAnnounce = true; else { @@ -395,6 +423,11 @@ public static partial class Command /// public bool UseAuthenticodeSigning => _useAuthenticodeSigning!.Value; + /// + /// Returns a value indicating if authenticode signing is enabled + /// + public bool UseJsingToolForAuthenticode => _useJsignToolForAuthenticode!.Value; + /// /// Returns a value indicating if notarize is enabled /// @@ -451,7 +484,7 @@ public static partial class Command /// Password for the password file /// The Authenticode password private string GetAuthenticodePassword(string keyfilepassword) - => EncryptionHelper.DecryptPasswordFile(Program.Configuration.ConfigFiles.AuthenticodePasswordFile, keyfilepassword).Trim(); + => EncryptionHelper.DecryptPasswordFile(Configuration.ConfigFiles.AuthenticodePasswordFile, keyfilepassword).Trim(); /// /// Performs authenticode signing if enabled @@ -459,26 +492,48 @@ public static partial class Command /// The file to sign /// An awaitable task public Task AuthenticodeSign(string file) - => UseAuthenticodeSigning - ? ProcessRunner.OsslCodeSign( - Program.Configuration.Commands.OsslSignCode!, - Program.Configuration.ConfigFiles.AuthenticodePfxFile, + { + if (!UseAuthenticodeSigning) + return Task.CompletedTask; + + if (UseJsingToolForAuthenticode) + return ProcessRunner.JsignCodeSign(Configuration.Commands.JSign!, Input.SignkeyPin, file); + else + return ProcessRunner.OsslCodeSign( + Configuration.Commands.SignCode!, + Configuration.ConfigFiles.AuthenticodePfxFile, PfxPassword, - file) - : Task.CompletedTask; + file + ); + } /// /// Performs codesign on the given file /// /// The file to sign + /// A value indicating if deep signing should be used /// The entitlements to apply /// An awaitable task - public Task Codesign(string file, string entitlements) + public Task Codesign(string file, bool deep, string entitlements) => UseCodeSignSigning ? ProcessRunner.MacOSCodeSign( - Program.Configuration.Commands.Codesign!, - Program.Configuration.ConfigFiles.CodesignIdentity, + Configuration.Commands.Codesign!, + Configuration.ConfigFiles.CodesignIdentity, entitlements, + file, + deep + ) + : Task.CompletedTask; + + /// + /// Verifies the codesign of the given file or app bundle + /// + /// The file to verify + /// An awaitable task + public Task VerifyCodeSign(string file) + => UseCodeSignSigning + ? ProcessRunner.MacOSVerifyCodeSign( + Configuration.Commands.Codesign!, file ) : Task.CompletedTask; @@ -491,8 +546,8 @@ public static partial class Command public Task Productsign(string file) => UseCodeSignSigning ? ProcessRunner.MacOSProductSign( - Program.Configuration.Commands.Productsign!, - Program.Configuration.ConfigFiles.CodesignIdentity, + Configuration.Commands.Productsign!, + Configuration.ConfigFiles.CodesignIdentity, file ) : Task.CompletedTask; diff --git a/ReleaseBuilder/Build/Command.Upload.cs b/ReleaseBuilder/Build/Command.Upload.cs index 6f07cbd5c..c16a3d9b7 100644 --- a/ReleaseBuilder/Build/Command.Upload.cs +++ b/ReleaseBuilder/Build/Command.Upload.cs @@ -106,17 +106,18 @@ public static partial class Command /// /// The files to upload /// The runtime configuration + /// The release channels to propagate to /// An awaitable task - public static async Task UploadToS3(IEnumerable files, RuntimeConfig rtcfg) + public static async Task UploadToS3(IEnumerable files, RuntimeConfig rtcfg, IEnumerable propagateTo) { var totalSize = files.Sum(f => new FileInfo(f.Path).Length); Console.WriteLine($"Uploading {files.Count()} files ({Duplicati.Library.Utility.Utility.FormatSizeString(totalSize)}) to S3..."); var chain = new Amazon.Runtime.CredentialManagement.CredentialProfileStoreChain(); - if (!chain.TryGetAWSCredentials(Program.Configuration.ConfigFiles.AwsUploadProfile, out var awsCredentials)) - throw new Exception($"The aws-cli profile '{Program.Configuration.ConfigFiles.AwsUploadProfile}' could not be found."); + if (!chain.TryGetAWSCredentials(rtcfg.Configuration.ConfigFiles.AwsUploadProfile, out var awsCredentials)) + throw new Exception($"The aws-cli profile '{rtcfg.Configuration.ConfigFiles.AwsUploadProfile}' could not be found."); - chain.TryGetProfile(Program.Configuration.ConfigFiles.AwsUploadProfile, out var awsProfile); + chain.TryGetProfile(rtcfg.Configuration.ConfigFiles.AwsUploadProfile, out var awsProfile); var config = new AmazonS3Config { RegionEndpoint = awsProfile?.Region ?? RegionEndpoint.USEast1, @@ -133,7 +134,7 @@ public static partial class Command await Duplicati.Library.Utility.RetryHelper.Retry(() => fileTransferUtility.UploadAsync( file.Path, - Program.Configuration.ConfigFiles.AwsUploadBucket, + rtcfg.Configuration.ConfigFiles.AwsUploadBucket, $"{rtcfg.ReleaseInfo.Channel.ToString().ToLowerInvariant()}/{file.Name}" ), 3, TimeSpan.FromSeconds(1), CancellationToken.None); @@ -141,6 +142,22 @@ public static partial class Command size += filesize; Console.WriteLine($"{size / (double)totalSize * 100:F1}% - Uploaded {file.Name} ({Duplicati.Library.Utility.Utility.FormatSizeString(filesize)})"); } + + foreach (var channel in propagateTo) + { + if (channel == rtcfg.ReleaseInfo.Channel) + continue; + + var target = $"{channel.ToString().ToLowerInvariant()}/latest-v2.manifest"; + var source = $"{rtcfg.ReleaseInfo.Channel.ToString().ToLowerInvariant()}/latest-v2.manifest"; + await Duplicati.Library.Utility.RetryHelper.Retry(() => + client.CopyObjectAsync( + rtcfg.Configuration.ConfigFiles.AwsUploadBucket, + source, + rtcfg.Configuration.ConfigFiles.AwsUploadBucket, + target + ), 3, TimeSpan.FromSeconds(1), CancellationToken.None); + } } /// @@ -151,11 +168,14 @@ public static partial class Command /// An awaitable task public static async Task UploadToGithub(IEnumerable files, RuntimeConfig rtcfg) { + var commithash = (await ProcessHelper.ExecuteWithOutput(["git", "rev-parse", "HEAD"]))?.Trim() + ?? throw new Exception("Failed to get the current commit hash"); + using var httpClient = new HttpClient(); var totalSize = files.Sum(f => new FileInfo(f.Path).Length); Console.WriteLine($"Uploading {files.Count()} files ({Duplicati.Library.Utility.Utility.FormatSizeString(totalSize)}) to Github..."); - var ghtoken = File.ReadAllText(Program.Configuration.ConfigFiles.GithubTokenFile).Trim(); + var ghtoken = File.ReadAllText(rtcfg.Configuration.ConfigFiles.GithubTokenFile).Trim(); var owner = "duplicati"; var repo = "duplicati"; @@ -166,7 +186,7 @@ public static partial class Command request.Headers.Add("User-Agent", "Duplicati Release Builder v1"); request.Content = JsonContent.Create(new GhReleaseInfo( tag_name: $"v{rtcfg.ReleaseInfo.ReleaseName}", - target_commitish: "master", + target_commitish: commithash, name: $"v{rtcfg.ReleaseInfo.ReleaseName}", body: rtcfg.ChangelogNews, draft: false, @@ -211,18 +231,18 @@ public static partial class Command /// /// The runtime configuration /// An awaitable task - public static async Task ReloadUpdateServer(RuntimeConfig rtcfg) + public static async Task ReloadUpdateServer(RuntimeConfig rtcfg, IEnumerable propagateTo) { using var client = new HttpClient(); var req = new HttpRequestMessage(HttpMethod.Post, "https://updates.duplicati.com/reload"); - var reloadToken = Program.Configuration.ConfigFiles.ReloadUpdatesApiKey; + var reloadToken = rtcfg.Configuration.ConfigFiles.ReloadUpdatesApiKey; req.Headers.Add("X-API-KEY", reloadToken); req.Content = JsonContent.Create(new[] { $"{rtcfg.ReleaseInfo.Channel.ToString().ToLowerInvariant()}/latest-v2.json", $"{rtcfg.ReleaseInfo.Channel.ToString().ToLowerInvariant()}/latest-v2.js", $"{rtcfg.ReleaseInfo.Channel.ToString().ToLowerInvariant()}/latest-v2.manifest", - }); + }.Concat(propagateTo.Select(c => $"{c.ToString().ToLowerInvariant()}/latest-v2.manifest"))); var response = await client.SendAsync(req); response.EnsureSuccessStatusCode(); @@ -238,7 +258,7 @@ public static partial class Command using var client = new HttpClient(); var req = new HttpRequestMessage(HttpMethod.Post, "https://forum.duplicati.com/posts"); - var discourseToken = File.ReadAllText(Program.Configuration.ConfigFiles.DiscourseTokenFile).Trim().Split(":", 2); + var discourseToken = File.ReadAllText(rtcfg.Configuration.ConfigFiles.DiscourseTokenFile).Trim().Split(":", 2); req.Headers.Add("Api-Username", discourseToken[0]); req.Headers.Add("Api-Key", discourseToken[1]); req.Headers.Add("Accept", "application/json"); diff --git a/ReleaseBuilder/Build/Command.cs b/ReleaseBuilder/Build/Command.cs index 84c25fb60..6f2579fb5 100644 --- a/ReleaseBuilder/Build/Command.cs +++ b/ReleaseBuilder/Build/Command.cs @@ -86,6 +86,8 @@ public static partial class Command { "Duplicati.CommandLine.Snapshots", "duplicati-snapshots" }, { "Duplicati.CommandLine.ServerUtil", "duplicati-server-util" }, { "Duplicati.CommandLine.SecretTool", "duplicati-secret-tool" }, + { "Duplicati.CommandLine.SyncTool", "duplicati-sync-tool" }, + { "Duplicati.CommandLine.SourceTool", "duplicati-source-tool" }, { "Duplicati.Service", "duplicati-service" }, { "Duplicati.Agent", "duplicati-agent" }, { "Duplicati.CommandLine", "duplicati-cli" }, @@ -221,6 +223,12 @@ public static partial class Command var passwordOption = SharedOptions.passwordOption; + var signkeyPinOption = new Option( + name: "--signkey-pin", + description: "The pin to use for the signing key", + getDefaultValue: () => string.Empty + ); + var disableDockerPushOption = new Option( name: "--disable-docker-push", description: "Disables pushing the docker image to the repository", @@ -299,6 +307,12 @@ public static partial class Command getDefaultValue: () => false ); + var propagateReleaseOption = new Option( + name: "--propagate-release", + description: "Propagate the release to the next channel", + getDefaultValue: () => false + ); + var command = new System.CommandLine.Command("build", "Builds the packages for a release") { gitStashPushOption, releaseChannelArgument, @@ -310,6 +324,7 @@ public static partial class Command disableAuthenticodeOption, disableCodeSignOption, passwordOption, + signkeyPinOption, macOsAppNameOption, disableDockerPushOption, dockerRepoOption, @@ -322,7 +337,8 @@ public static partial class Command disableUpdateServerReloadOption, disableDiscordAnnounceOption, useHostedBuildsOption, - resumeFromUploadOption + resumeFromUploadOption, + propagateReleaseOption }; command.Handler = CommandHandler.Create(DoBuild); @@ -343,6 +359,7 @@ public static partial class Command /// If authenticode signing should be disabled /// If signcode should be disabled /// The password to use for the keyfile + /// The pin to use for the signing key /// If the docker push should be disabled /// The name of the MacOS app bundle /// The docker repository to push to @@ -355,6 +372,7 @@ public static partial class Command /// If forum posting should be disabled /// If hosted builds should be used /// If the process should resume from the upload step + /// If the release should be propagated to the next channel record CommandInput( PackageTarget[] Targets, DirectoryInfo BuildPath, @@ -367,6 +385,7 @@ public static partial class Command bool DisableAuthenticode, bool DisableSignCode, string Password, + string SignkeyPin, bool DisableDockerPush, string MacOSAppName, string DockerRepo, @@ -378,12 +397,14 @@ public static partial class Command bool DisableUpdateServerReload, bool DisableDiscordAnnounce, bool UseHostedBuilds, - bool ResumeFromUpload + bool ResumeFromUpload, + bool PropagateRelease ); static async Task DoBuild(CommandInput input) { Console.WriteLine($"Building {input.Channel} release ..."); + var configuration = Configuration.Create(input.Channel); var buildTargets = input.Targets; @@ -394,15 +415,15 @@ public static partial class Command throw new FileNotFoundException($"Solution file not found: {input.SolutionFile.FullName}"); // This could be fixed, so we will throw an exception if the build is not possible - if (buildTargets.Any(x => x.Package == PackageType.MSI) && !Program.Configuration.IsMSIBuildPossible()) + if (buildTargets.Any(x => x.Package == PackageType.MSI) && !configuration.IsMSIBuildPossible()) throw new Exception("WiX toolset not configured, cannot build MSI files"); // This will be fixed in the future, but requires a new http-interface for Synology DSM - if (buildTargets.Any(x => x.Package == PackageType.SynologySpk) && !Program.Configuration.IsSynologyPkgPossible()) + if (buildTargets.Any(x => x.Package == PackageType.SynologySpk) && !configuration.IsSynologyPkgPossible()) throw new Exception("Synology SPK files are currently not supported"); // This will not work, so to make it easier for non-MacOS developers, we will remove the MacOS packages - if (buildTargets.Any(x => x.Package == PackageType.MacPkg || x.Package == PackageType.DMG) && !Program.Configuration.IsMacPkgBuildPossible()) + if (buildTargets.Any(x => x.Package == PackageType.MacPkg || x.Package == PackageType.DMG) && !configuration.IsMacPkgBuildPossible()) { Console.WriteLine("MacOS packages requested but not running on MacOS, removing from build targets"); buildTargets = buildTargets.Where(x => x.Package != PackageType.MacPkg && x.Package != PackageType.DMG).ToArray(); @@ -471,13 +492,14 @@ public static partial class Command if (string.IsNullOrWhiteSpace(keyfilePassword)) keyfilePassword = ConsoleHelper.ReadPassword("Enter keyfile password"); - var primarySignKey = LoadKeyFile(Program.Configuration.ConfigFiles.UpdaterKeyfile.FirstOrDefault(), keyfilePassword, false); - var additionalKeys = Program.Configuration.ConfigFiles.UpdaterKeyfile + var primarySignKey = LoadKeyFile(configuration.ConfigFiles.UpdaterKeyfile.FirstOrDefault(), keyfilePassword, false); + var additionalKeys = configuration.ConfigFiles.UpdaterKeyfile .Skip(1) .Select(x => LoadKeyFile(x, keyfilePassword, true)); // Configure runtime environment var rtcfg = new RuntimeConfig( + configuration, releaseInfo, additionalKeys.Prepend(primarySignKey).ToList(), keyfilePassword, @@ -580,11 +602,11 @@ public static partial class Command }), Path.Combine(input.BuildPath.FullName, "packages"), version: releaseInfo.Version.ToString(), - incompatibleUpdateUrl: ReplaceVersionPlaceholders(Program.Configuration.ExtraSettings.UpdateFromIncompatibleVersionUrl, releaseInfo), - genericUpdatePageUrl: ReplaceVersionPlaceholders(Program.Configuration.ExtraSettings.GenericUpdatePageUrl, releaseInfo), + incompatibleUpdateUrl: ReplaceVersionPlaceholders(configuration.ExtraSettings.UpdateFromIncompatibleVersionUrl, releaseInfo), + genericUpdatePageUrl: ReplaceVersionPlaceholders(configuration.ExtraSettings.GenericUpdatePageUrl, releaseInfo), releaseType: releaseInfo.Channel.ToString().ToLowerInvariant(), packages: builtPackages.Select(x => new PackageEntry( - RemoteUrls: Program.Configuration.ExtraSettings.PackageUrls + RemoteUrls: configuration.ExtraSettings.PackageUrls .Select(u => ReplaceVersionPlaceholders(u, releaseInfo) .Replace("${FILENAME}", HttpUtility.UrlEncode(Path.GetFileName(x.CreatedFile))) @@ -619,6 +641,13 @@ public static partial class Command if (input.GitStashPush && !input.ResumeFromUpload) await GitPush.TagAndPush(baseDir, releaseInfo); + // Propagate the release to the next channel, if selected + var nextChannels = new[] { ReleaseChannel.Experimental, ReleaseChannel.Beta, ReleaseChannel.Stable } + .Where(x => x > releaseInfo.Channel) + .Where(x => rtcfg.ReleaseInfo.Channel != ReleaseChannel.Canary) + .Where(x => input.PropagateRelease) + .ToArray(); + if (rtcfg.UseS3Upload || rtcfg.UseGithubUpload) { Console.WriteLine("Build completed, uploading packages ..."); @@ -636,7 +665,7 @@ public static partial class Command .Append(new Upload.UploadFile(packageJson, Path.GetFileName(packageJson))) .Append(new Upload.UploadFile(packageJson, $"latest-v2-{releaseInfo.Version}.json")); - await Upload.UploadToS3(uploads, rtcfg); + await Upload.UploadToS3(uploads, rtcfg, nextChannels); } if (rtcfg.UseGithubUpload) @@ -657,7 +686,7 @@ public static partial class Command if (rtcfg.UseUpdateServerReload) { Console.WriteLine("Release completed, reloading update server ..."); - await Upload.ReloadUpdateServer(rtcfg); + await Upload.ReloadUpdateServer(rtcfg, nextChannels); } if (rtcfg.UseForumPosting) @@ -767,7 +796,7 @@ public static partial class Command /// Modified files static Task> PrepareSourceDirectory(string baseDir, ReleaseInfo releaseInfo, RuntimeConfig rtcfg) { - var urlstring = string.Join(";", Program.Configuration.ExtraSettings.UpdaterUrls.Select(x => + var urlstring = string.Join(";", rtcfg.Configuration.ExtraSettings.UpdaterUrls.Select(x => ReplaceVersionPlaceholders(x, releaseInfo) .Replace("${FILENAME}", HttpUtility.UrlEncode("latest-v2.manifest")) )); @@ -815,8 +844,8 @@ public static partial class Command JsonSerializer.Serialize(new { Displayname = $"Duplicati v{releaseInfo.Version} - {releaseInfo.Channel}" }), Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater"), version: releaseInfo.Version.ToString(), - incompatibleUpdateUrl: ReplaceVersionPlaceholders(Program.Configuration.ExtraSettings.UpdateFromIncompatibleVersionUrl, releaseInfo), - genericUpdatePageUrl: ReplaceVersionPlaceholders(Program.Configuration.ExtraSettings.GenericUpdatePageUrl, releaseInfo), + incompatibleUpdateUrl: ReplaceVersionPlaceholders(rtcfg.Configuration.ExtraSettings.UpdateFromIncompatibleVersionUrl, releaseInfo), + genericUpdatePageUrl: ReplaceVersionPlaceholders(rtcfg.Configuration.ExtraSettings.GenericUpdatePageUrl, releaseInfo), releaseType: releaseInfo.Channel.ToString().ToLowerInvariant() ); @@ -851,7 +880,7 @@ public static partial class Command foreach (var target in targets) { - if (string.IsNullOrWhiteSpace(Program.Configuration.Commands.Npm)) + if (string.IsNullOrWhiteSpace(rtcfg.Configuration.Commands.Npm)) throw new Exception("NPM command not found, but required for building"); // Remove existing node_modules folder @@ -869,7 +898,7 @@ public static partial class Command EnvHelper.CopyDirectory(target, tmp, true); // Run npm install in the temporary folder - await ProcessHelper.Execute(new[] { Program.Configuration.Commands.Npm, "ci" }, workingDirectory: tmp); + await ProcessHelper.Execute([rtcfg.Configuration.Commands.Npm, "ci"], workingDirectory: tmp); var basefolder = Directory.EnumerateDirectories(Path.Combine(tmp, "node_modules"), "*", SearchOption.AllDirectories) .Where(x => File.Exists(Path.Combine(x, "index.html"))) .OrderBy(x => x.Length) diff --git a/ReleaseBuilder/Configuration.cs b/ReleaseBuilder/Configuration.cs index 879843f10..b20b5934a 100644 --- a/ReleaseBuilder/Configuration.cs +++ b/ReleaseBuilder/Configuration.cs @@ -68,21 +68,35 @@ public record Configuration( /// /// Creates a new /// + /// The release channel /// The new configuration - public static Configuration Create() + public static Configuration Create(ReleaseChannel channel) => new( - ConfigFiles.Create(), + ConfigFiles.Create(channel), Commands.Create(), ExtraSettings.Create() ); + /// - /// Checks if signing with authenticode is possible given the current configuration + /// Checks if signing with authenticode using jsign is possible given the current configuration /// /// A boolean indicating if signing is possible - public bool IsAuthenticodePossible() + public bool IsAuthenticodePossibleWithJsignTool() { - if (string.IsNullOrWhiteSpace(ConfigFiles.AuthenticodePasswordFile) || string.IsNullOrWhiteSpace(ConfigFiles.AuthenticodePfxFile) || string.IsNullOrWhiteSpace(Commands.OsslSignCode)) + if (string.IsNullOrWhiteSpace(Commands.JSign)) + return false; + + return true; + } + + /// + /// Checks if signing with authenticode is possible with signtool given the current configuration + /// + /// A boolean indicating if signing is possible + public bool IsAuthenticodePossibleWithSignTool() + { + if (string.IsNullOrWhiteSpace(ConfigFiles.AuthenticodePasswordFile) || string.IsNullOrWhiteSpace(ConfigFiles.AuthenticodePfxFile) || string.IsNullOrWhiteSpace(Commands.SignCode)) return false; if (!File.Exists(ConfigFiles.AuthenticodePasswordFile) || !File.Exists(ConfigFiles.AuthenticodePfxFile)) @@ -283,12 +297,28 @@ public record ConfigFiles( /// /// Generates a new config files instance /// + /// The release channel /// The config files instance - public static ConfigFiles Create() + public static ConfigFiles Create(ReleaseChannel channel) { - ParseEnvironmentFile(ExpandEnv("BUILD_SETTINGS_FILE", "${HOME}/.config/build-settings")); - ParseEnvironmentFile(ExpandEnv("GATEKEEPER_SETTINGS_FILE", "${HOME}/.config/signkeys/Duplicati/macos-gatekeeper")); + // Grab the shared configuration + ParseEnvironmentFile(ExpandEnv("BUILD_SETTINGS_FILE", "${HOME}/.config/duplicati-build-settings")); + + // Override the configuration for the release type, if any + ParseEnvironmentFile(ExpandEnv(channel switch + { + ReleaseChannel.Stable or + ReleaseChannel.Beta or + ReleaseChannel.Experimental or + ReleaseChannel.Canary => "${HOME}/.config/signkeys/Duplicati/release-build-settings", + ReleaseChannel.Nightly => "${HOME}/.config/signkeys/Duplicati/nightly-build-settings-nightly", + ReleaseChannel.Debug => "${HOME}/.config/signkeys/Duplicati/debug-build-settings", + _ => throw new ArgumentOutOfRangeException(nameof(channel)) + })); + + // Override the configuration for the release channel, if any + ParseEnvironmentFile(ExpandEnv($"${{HOME}}/.config/signkeys/Duplicati/${channel.ToString().ToLowerInvariant()}-build-settings")); return new( ExpandEnv("UPDATER_KEYFILE", "${HOME}/.config/signkeys/Duplicati/updater-release.key").Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), @@ -312,7 +342,8 @@ public record ConfigFiles( /// The "build" command /// The "gpg" command /// The "aws" command -/// The "osslsigncode" command +/// The "osslsigncode" command +/// The "jsign" command /// The "codesign" command /// The "productsign" command /// The "wix" command @@ -321,7 +352,8 @@ public record ConfigFiles( public record Commands( string Dotnet, string? Gpg, - string? OsslSignCode, + string? SignCode, + string? JSign, string? Codesign, string? Productsign, string? Wix, @@ -338,6 +370,7 @@ public record Commands( FindCommand("dotnet", "DOTNET") ?? throw new Exception("Failed to find the \"dotnet\" command"), FindCommand("gpg2", "GPG", FindCommand("gpg", "GPG")), FindCommand(OperatingSystem.IsWindows() ? "signtool.exe" : "osslsigncode", "SIGNTOOL"), + FindCommand("jsign", "JSIGNTOOL"), OperatingSystem.IsMacOS() ? FindCommand("codesign", "CODESIGN") : null, OperatingSystem.IsMacOS() ? FindCommand("productsign", "PRODUCTSIGN") : null, FindCommand(OperatingSystem.IsWindows() ? "wix" : "wixl", "WIX"), diff --git a/ReleaseBuilder/CreateKey/Command.cs b/ReleaseBuilder/CreateKey/Command.cs index bc0dca2d3..8443896d7 100644 --- a/ReleaseBuilder/CreateKey/Command.cs +++ b/ReleaseBuilder/CreateKey/Command.cs @@ -32,7 +32,7 @@ public static class Command var keyfileArgument = new Argument( name: "keyfile", description: "Path to keyfile to use for signing release manifests", - getDefaultValue: () => new FileInfo(Program.Configuration.ConfigFiles.UpdaterKeyfile.FirstOrDefault() ?? "./signkey.key") + getDefaultValue: () => new FileInfo(Configuration.Create(ReleaseChannel.Debug).ConfigFiles.UpdaterKeyfile.FirstOrDefault() ?? "./signkey.key") ); var command = new System.CommandLine.Command("create-key", "Creates a new key for signing releases") diff --git a/ReleaseBuilder/EnvHelper.cs b/ReleaseBuilder/EnvHelper.cs index c05e662b6..faadddbba 100644 --- a/ReleaseBuilder/EnvHelper.cs +++ b/ReleaseBuilder/EnvHelper.cs @@ -41,11 +41,20 @@ public static class EnvHelper if (string.IsNullOrWhiteSpace(value)) value = defaultValue ?? string.Empty; + return ExpandEnv(value); + } + + /// + /// Reads the environment key, and expands environment variables inside. + /// If no key is found, the default value is returned + /// + /// The key to use + /// The expanded string + public static string ExpandEnv(string value) // Bash-style env expansion "${name}", done after normal env expansion - return Regex.Replace(Environment.ExpandEnvironmentVariables(value), "\\${(?[^}]+)}", m => + => Regex.Replace(Environment.ExpandEnvironmentVariables(value), "\\${(?[^}]+)}", m => Environment.GetEnvironmentVariable(m.Groups["name"].Value) ?? string.Empty ); - } /// /// Reads the environment key, and expands environment variables inside. @@ -197,7 +206,7 @@ public static class EnvHelper var targetEntry = Path.GetFileName(path); // Use docker to set the ownership - await ProcessHelper.Execute(new[] { "docker", "run", "--mount", $"type=bind,source={baseFolder},target=/opt/mount", "alpine:latest", "chown", recursive ? "-R" : "", $"{uid}:{gid}", Path.Combine("/opt/mount", targetEntry) }); + await ProcessHelper.Execute(["docker", "run", "--mount", $"type=bind,source={baseFolder},target=/opt/mount", "alpine:latest", "chown", recursive ? "-R" : "", $"{uid}:{gid}", Path.Combine("/opt/mount", targetEntry)]); } /// diff --git a/ReleaseBuilder/ProcessHelper.cs b/ReleaseBuilder/ProcessHelper.cs index 7a6f74e45..8486fd2d9 100644 --- a/ReleaseBuilder/ProcessHelper.cs +++ b/ReleaseBuilder/ProcessHelper.cs @@ -38,10 +38,14 @@ public static class ProcessHelper /// If true, stderr is not forwarded to the console /// Function to write to stdin /// An awaitable task - public static async Task Execute(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false, Func? writeStdIn = null) + public static async Task Execute(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false, Func? writeStdIn = null) { if (!command.Any()) throw new ArgumentException("Needs at least one command", nameof(command)); + var executable = command.First(); + if (string.IsNullOrWhiteSpace(executable)) + throw new ArgumentException("Executable name cannot be empty", nameof(command)); + workingDirectory ??= Environment.CurrentDirectory; if (!Directory.Exists(workingDirectory)) @@ -49,7 +53,7 @@ public static class ProcessHelper codeIsError ??= (x) => x != 0; - var p = Process.Start(new ProcessStartInfo(command.First(), command.Skip(1)) + var p = Process.Start(new ProcessStartInfo(executable, command.Skip(1).Where(x => !string.IsNullOrEmpty(x)).Select(x => x!)) { WindowStyle = ProcessWindowStyle.Hidden, WorkingDirectory = workingDirectory, @@ -57,7 +61,7 @@ public static class ProcessHelper RedirectStandardOutput = false, RedirectStandardInput = writeStdIn != null, UseShellExecute = false, - }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned"); + }) ?? throw new Exception($"Failed to launch process {executable}, null returned"); // Forward error messages to stderr var t = suppressStdErr @@ -69,7 +73,7 @@ public static class ProcessHelper await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false); if (codeIsError(p.ExitCode)) - throw new Exception($"Execution of {command.First()} gave error code {p.ExitCode}"); + throw new Exception($"Execution of {executable} gave error code {p.ExitCode}"); await t.ConfigureAwait(false); } @@ -84,7 +88,7 @@ public static class ProcessHelper /// Default value is null which will treat anything non-zero as an error /// If true, stderr is not forwarded to the console /// An awaitable task - public static async Task ExecuteAll(IEnumerable> commands, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false) + public static async Task ExecuteAll(IEnumerable> commands, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false) { foreach (var c in commands) await Execute(c, workingDirectory, cancellationToken, codeIsError, suppressStdErr).ConfigureAwait(false); @@ -101,10 +105,13 @@ public static class ProcessHelper /// If true, stderr is not forwarded to the console /// Function to write to stdin /// The output from stdout - public static async Task ExecuteWithOutput(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false, Func? writeStdIn = null) + public static async Task ExecuteWithOutput(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false, Func? writeStdIn = null) { if (!command.Any()) throw new ArgumentException("Needs at least one command", nameof(command)); + var executable = command.First(); + if (string.IsNullOrWhiteSpace(executable)) + throw new ArgumentException("Executable name cannot be empty", nameof(command)); workingDirectory ??= Environment.CurrentDirectory; if (!Directory.Exists(workingDirectory)) @@ -112,7 +119,7 @@ public static class ProcessHelper codeIsError ??= (x) => x != 0; - var p = Process.Start(new ProcessStartInfo(command.First(), command.Skip(1)) + var p = Process.Start(new ProcessStartInfo(executable, command.Skip(1).Where(x => !string.IsNullOrEmpty(x)).Select(x => x!)) { WindowStyle = ProcessWindowStyle.Hidden, WorkingDirectory = workingDirectory, @@ -120,7 +127,7 @@ public static class ProcessHelper RedirectStandardOutput = true, RedirectStandardInput = writeStdIn != null, UseShellExecute = false, - }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned"); + }) ?? throw new Exception($"Failed to launch process {executable}, null returned"); var tstdout = p.StandardOutput.ReadToEndAsync(cancellationToken); var tstderr = suppressStdErr @@ -132,7 +139,7 @@ public static class ProcessHelper await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false); if (codeIsError(p.ExitCode)) - throw new Exception($"Execution of {command.First()} gave error code {p.ExitCode}"); + throw new Exception($"Execution of {executable} gave error code {p.ExitCode}"); await tstderr.ConfigureAwait(false); return await tstdout.ConfigureAwait(false); @@ -151,10 +158,13 @@ public static class ProcessHelper /// If true, stderr is not forwarded to the console /// Function to write to stdin /// The output from stdout - public static async Task ExecuteWithOutput(IEnumerable command, Stream stdout, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false, Func? writeStdIn = null) + public static async Task ExecuteWithOutput(IEnumerable command, Stream stdout, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false, Func? writeStdIn = null) { if (!command.Any()) throw new ArgumentException("Needs at least one command", nameof(command)); + var executable = command.First(); + if (string.IsNullOrWhiteSpace(executable)) + throw new ArgumentException("Executable name cannot be empty", nameof(command)); workingDirectory ??= Environment.CurrentDirectory; if (!Directory.Exists(workingDirectory)) @@ -162,7 +172,7 @@ public static class ProcessHelper codeIsError ??= (x) => x != 0; - var p = Process.Start(new ProcessStartInfo(command.First(), command.Skip(1)) + var p = Process.Start(new ProcessStartInfo(executable, command.Skip(1).Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!)) { WindowStyle = ProcessWindowStyle.Hidden, WorkingDirectory = workingDirectory, @@ -170,7 +180,7 @@ public static class ProcessHelper RedirectStandardOutput = true, RedirectStandardInput = writeStdIn != null, UseShellExecute = false, - }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned"); + }) ?? throw new Exception($"Failed to launch process {executable}, null returned"); var tstdout = p.StandardOutput.BaseStream.CopyToAsync(stdout, cancellationToken); var tstderr = suppressStdErr @@ -182,7 +192,7 @@ public static class ProcessHelper await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false); if (codeIsError(p.ExitCode)) - throw new Exception($"Execution of {command.First()} gave error code {p.ExitCode}"); + throw new Exception($"Execution of {executable} gave error code {p.ExitCode}"); await tstderr.ConfigureAwait(false); await tstdout.ConfigureAwait(false); @@ -200,10 +210,13 @@ public static class ProcessHelper /// Default value is null which will treat anything non-zero as an error /// Function to write to stdin /// The output from stdout - public static async Task ExecuteWithLog(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, string? logFolder = null, Func? logFilename = null, Func? writeStdIn = null) + public static async Task ExecuteWithLog(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, string? logFolder = null, Func? logFilename = null, Func? writeStdIn = null) { if (!command.Any()) throw new ArgumentException("Needs at least one command", nameof(command)); + var executable = command.First(); + if (string.IsNullOrWhiteSpace(executable)) + throw new ArgumentException("Executable name cannot be empty", nameof(command)); workingDirectory ??= Environment.CurrentDirectory; if (!Directory.Exists(workingDirectory)) @@ -213,7 +226,7 @@ public static class ProcessHelper codeIsError ??= (x) => x != 0; - var p = Process.Start(new ProcessStartInfo(command.First(), command.Skip(1)) + var p = Process.Start(new ProcessStartInfo(executable, command.Skip(1).Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!)) { WindowStyle = ProcessWindowStyle.Hidden, WorkingDirectory = workingDirectory, @@ -221,9 +234,9 @@ public static class ProcessHelper RedirectStandardOutput = true, RedirectStandardInput = writeStdIn != null, UseShellExecute = false, - }) ?? throw new Exception($"Failed to launch process {command.First()}, null returned"); + }) ?? throw new Exception($"Failed to launch process {executable}, null returned"); - logFilename ??= (pid, isStdOut) => $"{command.First()}-{p.Id}.{(isStdOut ? "stdout" : "stderr")}.log"; + logFilename ??= (pid, isStdOut) => $"{executable}-{p.Id}.{(isStdOut ? "stdout" : "stderr")}.log"; using var logstdout = File.Create(Path.Combine(logFolder, logFilename(p.Id, true))); using var logstderr = File.Create(Path.Combine(logFolder, logFilename(p.Id, false))); @@ -236,7 +249,7 @@ public static class ProcessHelper await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false); if (codeIsError(p.ExitCode)) - throw new Exception($"Execution of {command.First()} gave error code {p.ExitCode}"); + throw new Exception($"Execution of {executable} gave error code {p.ExitCode}, see log file {Path.Combine(logFolder, logFilename(p.Id, true))}"); await t1.ConfigureAwait(false); await t2.ConfigureAwait(false); diff --git a/ReleaseBuilder/ProcessRunner.cs b/ReleaseBuilder/ProcessRunner.cs index edc9cd509..060013644 100644 --- a/ReleaseBuilder/ProcessRunner.cs +++ b/ReleaseBuilder/ProcessRunner.cs @@ -30,12 +30,43 @@ public static class ProcessRunner /// /// The company name to encode in the Authenticode certificate /// - private const string OSSLOrganization = "Duplicati"; + private const string OSSLOrganization = "Duplicati Inc"; /// /// The url to encode in the Authenticode certificate /// private const string OSSLUrl = "https://duplicati.com"; + /// + /// Performs code signing of the + /// + /// The path to the jsign binary + /// The password to decrypt the PFX file + /// The executable to sign, in-place + /// An awaitable task + public static async Task JsignCodeSign(string jsign, string keypin, string executable) + { + var first = true; + foreach (var hashalg in OSSLHashAlgs) + { + await ProcessHelper.Execute([ + jsign, "sign", + "--storetype", "PIV", + "--storepass", keypin, + "--alias", "AUTHENTICATION", + "--name", OSSLOrganization, + "--url", OSSLUrl, + "--alg", hashalg, + first ? "--replace" : null, + "--tsaurl", "http://ts.ssl.com", + "--tsmode", "RFC3161", + executable + ]); + + + first = false; + } + } + /// /// Performs code signing of the /// @@ -52,20 +83,29 @@ public static class ProcessRunner var tmp = Path.GetTempFileName(); File.Delete(tmp); - var args = new[] { + // It is also possible to use a PKCS#11 module for the key, like this: + // osslsigncode sign -h sha256 + // -pkcs11module /opt/homebrew/lib/libykcs11.dylib + // -certs public-key.pem + // -key 'pkcs11:pin-value={pin}' + // -ts http://ts.ssl.com + // -in {executable} + // -out {tmp} + + + await ProcessHelper.Execute([ osslsigncode, "sign", "-pkcs12", pfxfile, "-pass", pfxpassword, "-n", OSSLOrganization, "-i", OSSLUrl, "-h", hashalg, - first ? "" : "-nest", + first ? null : "-nest", "-t", $"http://timestamp.digicert.com?alg={hashalg}", "-in", executable, "-out", tmp - }; + ]); - await ProcessHelper.Execute(args.Where(x => !string.IsNullOrWhiteSpace(x))); File.Move(tmp, executable, true); first = false; @@ -79,10 +119,12 @@ public static class ProcessRunner /// The identity used for codesign /// The entitlements to activate for the file /// The file to sign + /// Whether to sign deeply /// An awaitable task - public static Task MacOSCodeSign(string codesign, string codesignIdentity, string entitlementFile, string file) + public static Task MacOSCodeSign(string codesign, string codesignIdentity, string entitlementFile, string file, bool deep) => ProcessHelper.Execute([ codesign, + (deep ? "--deep" : null), "--force", "--timestamp", "--options=runtime", @@ -91,6 +133,21 @@ public static class ProcessRunner file ]); + /// + /// Verifies the MacOS codesign of a file or app bundle + /// + /// The path to the codesign binary + /// The file to verify + /// An awaitable task + public static Task MacOSVerifyCodeSign(string codesign, string file) + => ProcessHelper.Execute([ + codesign, + "--verify", + "--deep", + "--strict", + file + ]); + /// /// Runs MacOS codesign on a single file /// diff --git a/ReleaseBuilder/Program.cs b/ReleaseBuilder/Program.cs index 81ac16f80..290476c2f 100644 --- a/ReleaseBuilder/Program.cs +++ b/ReleaseBuilder/Program.cs @@ -1,23 +1,23 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. using System.CommandLine; @@ -61,6 +61,8 @@ class Program "linux-arm7-cli.zip", "linux-arm7-cli.deb", "linux-arm7-cli.docker", + "linux-arm7-agent.zip", + "linux-arm7-agent.deb", "linux-arm64-gui.zip", "linux-arm64-gui.deb", @@ -88,11 +90,6 @@ class Program .Distinct() .ToList(); - /// - /// The environment shared configuration - /// - public static readonly Configuration Configuration = Configuration.Create(); - /// /// The return code of the application; shared state /// diff --git a/ReleaseBuilder/Resources/Windows/Agent/License.rtf b/ReleaseBuilder/Resources/Windows/Agent/License.rtf index 9b2b7cff7..7198f91ff 100644 --- a/ReleaseBuilder/Resources/Windows/Agent/License.rtf +++ b/ReleaseBuilder/Resources/Windows/Agent/License.rtf @@ -6,7 +6,7 @@ \deftab720 \pard\pardeftab720\ri0\sl380\sa240\partightenfactor0 -\f0\fs25 \cf0 Copyright (c) 2024 Team Duplicati, https://duplicati.com, hello@duplicati.com +\f0\fs25 \cf0 Copyright (c) 2025 Team Duplicati, https://duplicati.com, hello@duplicati.com \f1\fs24 \ \pard\pardeftab720\ri0\sl380\sb240\sa240\partightenfactor0 diff --git a/ReleaseBuilder/Resources/Windows/TrayIcon/License.rtf b/ReleaseBuilder/Resources/Windows/TrayIcon/License.rtf index 9b2b7cff7..7198f91ff 100644 --- a/ReleaseBuilder/Resources/Windows/TrayIcon/License.rtf +++ b/ReleaseBuilder/Resources/Windows/TrayIcon/License.rtf @@ -6,7 +6,7 @@ \deftab720 \pard\pardeftab720\ri0\sl380\sa240\partightenfactor0 -\f0\fs25 \cf0 Copyright (c) 2024 Team Duplicati, https://duplicati.com, hello@duplicati.com +\f0\fs25 \cf0 Copyright (c) 2025 Team Duplicati, https://duplicati.com, hello@duplicati.com \f1\fs24 \ \pard\pardeftab720\ri0\sl380\sb240\sa240\partightenfactor0 diff --git a/ReleaseBuilder/WixHeatBuilder.cs b/ReleaseBuilder/WixHeatBuilder.cs index ba58137de..79fdcb1cf 100644 --- a/ReleaseBuilder/WixHeatBuilder.cs +++ b/ReleaseBuilder/WixHeatBuilder.cs @@ -18,6 +18,9 @@ // 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.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; using System.Xml; namespace ReleaseBuilder; @@ -27,6 +30,34 @@ namespace ReleaseBuilder; /// public static class WixHeatBuilder { + + /// + /// Converts a string to a valid Wix identifier. + /// + /// The input string. + /// The WiX identifier. + public static string ConvertToIdentifier(string input) + { + if (string.IsNullOrEmpty(input)) + throw new ArgumentException("Input string cannot be null or empty."); + + // Remove invalid characters (only allow A-Z, a-z, 0-9, _, .) + var cleanedInput = Regex.Replace(input, @"[^A-Za-z0-9_.]", "_"); + + if (cleanedInput.Length > 72) + { + var hash = BitConverter.ToString(SHA256.HashData(Encoding.UTF8.GetBytes(cleanedInput))) + .Replace("-", string.Empty)[..12]; + cleanedInput = cleanedInput[..(72 - hash.Length)] + hash; + } + + // Ensure the identifier starts with a letter or underscore + if (!char.IsLetter(cleanedInput[0]) && cleanedInput[0] != '_') + cleanedInput = "_" + cleanedInput; + + return cleanedInput; + } + /// /// Creates a Wix filelist from a directory /// @@ -35,14 +66,14 @@ public static class WixHeatBuilder /// The name of the component group /// A function to generate file IDs. /// The wix file xml contents - public static string CreateWixFilelist(string sourceFolder, string version, string folderPrefix = "$(var.HarvestPath)", string directoryRefName = "INSTALLLOCATION", string componentGroupId = "DUPLICATIBIN", Func? fileIdGenerator = null) + public static string CreateWixFilelist(string sourceFolder, string version, string folderPrefix = "$(var.HarvestPath)", string directoryRefName = "INSTALLLOCATION", string componentGroupId = "DUPLICATIBIN", string wixNs = "http://schemas.microsoft.com/wix/2006/wi", Func? fileIdGenerator = null) { var itemIds = new Dictionary(); - fileIdGenerator ??= (x) => Path.GetRelativePath(sourceFolder, x).Replace("\\", "_").Replace("/", "_").Replace(":", "_").Replace(" ", "_"); + fileIdGenerator ??= (x) => ConvertToIdentifier(Path.GetRelativePath(sourceFolder, x)); Func pathTransformer = (x) => $"{folderPrefix}{Path.GetRelativePath(sourceFolder, x)}"; var doc = new XmlDocument(); - doc.LoadXml(""); + doc.LoadXml($""); var root = doc.DocumentElement!; var fragment = doc.CreateElement("Fragment"); root.AppendChild(fragment); diff --git a/ReleaseBuilder/build_version.txt b/ReleaseBuilder/build_version.txt index 202bdd1a8..c4d3bba64 100644 --- a/ReleaseBuilder/build_version.txt +++ b/ReleaseBuilder/build_version.txt @@ -1 +1 @@ -2.1.0.107 \ No newline at end of file +2.1.0.111 \ No newline at end of file diff --git a/Tools/RemoteSynchronization/Program.cs b/Tools/RemoteSynchronization/Program.cs new file mode 100644 index 000000000..91c8ae793 --- /dev/null +++ b/Tools/RemoteSynchronization/Program.cs @@ -0,0 +1,830 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.NamingConventionBinder; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + + +namespace RemoteSynchronization +{ + /// + /// Remote synchronization tool. + /// + public class Program + { + /// + /// Global configuration for the tool. Should be set after parsing the commandline arguments. + /// + private sealed record Config + ( + // Arguments + string Src, + string Dst, + + // Options + bool Confirm, + bool DryRun, + List DstOptions, + bool Force, + List GlobalOptions, + string LogFile, + string LogLevel, + bool ParseArgumentsOnly, + bool Progress, + bool Retention, + int Retry, + List SrcOptions, + bool VerifyContents, + bool VerifyGetAfterPut + ); + + /// + /// The log tag for this tool. + /// + private static readonly string LOGTAG = Duplicati.Library.Logging.Log.LogTagFromType(); + + /// + /// Main entry point for the tool. + /// + /// The commandline arguments + /// 0 on success, -1 on abort, and the number of errors encountered otherwise. + public static async Task Main(string[] args) + { + var arg_src = new Argument(name: "backend_src", description: "The source backend string"); + var arg_dst = new Argument(name: "backend_dst", description: "The destination backend string"); + + var root_cmd = new RootCommand(@"Remote Synchronization Tool + +This tool synchronizes two remote backends. The tool assumes that the intent is +to have the destination match the source. + +If the destination has files that are not in the source, they will be deleted +(or renamed if the retention option is set). + +If the destination has files that are also present in the source, but the files +differ in size, or if the source files have a newer (more recent) timestamp, +the destination files will be overwritten by the source files. Given that some +backends do not allow for metadata or timestamp modification, and that the tool +is run after backup, the destination files should always have a timestamp that +is newer (or the same if run promptly) compared to the source files. + +If the force option is set, the destination will be overwritten by the source, +regardless of the state of the files. It will also skip the initial comparison, +and delete (or rename) all files in the destination. + +If the verify option is set, the files will be downloaded and compared after +uploading to ensure that the files are correct. Files that already exist in the +destination will be verified before being overwritten (if they seemingly match). + ") + { + arg_src, + arg_dst, + + new Option(aliases: ["--confirm", "--yes", "-y"], description: "Automatically confirm the operation", getDefaultValue: () => false), + new Option(aliases: ["--dry-run", "-d"], description: "Do not actually write or delete files. If not set here, the global options will be checked", getDefaultValue: () => false), + OptionWithMultipleTokens(aliases: ["--dst-options"], description: "Options for the destination backend. Each option is a key-value pair separated by an equals sign, e.g. --dst-options key1=value1 key2=value2 [default: empty]", getDefaultValue: () => []), + new Option(aliases: ["--force", "-f"], description: "Force the synchronization", getDefaultValue: () => false), + OptionWithMultipleTokens(aliases: ["--global-options"], description: "Global options all backends. May be overridden by backend specific options (src-options, dst-options). Each option is a key-value pair separated by an equals sign, e.g. --global-options key1=value1 key2=value2 [default: empty]", getDefaultValue: () => []), + new Option(aliases: ["--log-file"], description: "The log file to write to. If not set here, global options will be checked [default: \"\"]", getDefaultValue: () => "") { Arity = ArgumentArity.ExactlyOne }, + new Option(aliases: ["--log-level"], description: "The log level to use. If not set here, global options will be checked", getDefaultValue: () => "Information") { Arity = ArgumentArity.ExactlyOne }, + new Option(aliases: ["--parse-arguments-only"], description: "Only parse the arguments and then exit", getDefaultValue: () => false), + new Option(aliases: ["--progress"], description: "Print progress to STDOUT", getDefaultValue: () => false), + new Option(aliases: ["--retention"], description: "Toggles whether to keep old files. Any deletes will be renames instead", getDefaultValue: () => false), + new Option(aliases: ["--retry"], description: "Number of times to retry on errors", getDefaultValue: () => 3) { Arity = ArgumentArity.ExactlyOne }, + OptionWithMultipleTokens(aliases: ["--src-options"], description: "Options for the source backend. Each option is a key-value pair separated by an equals sign, e.g. --src-options key1=value1 key2=value2 [default: empty]", getDefaultValue: () => []), + new Option(aliases: ["--verify-contents"], description: "Verify the contents of the files to decide whether the pre-existing destination files should be overwritten", getDefaultValue: () => false), + new Option(aliases: ["--verify-get-after-put"], description: "Verify the files after uploading them to ensure that they were uploaded correctly", getDefaultValue: () => false), + }; + + root_cmd.Handler = CommandHandler.Create((string backend_src, string backend_dst, Config config) => + { + var config_with_args = config with { Dst = backend_dst, Src = backend_src }; + + return Run(config_with_args); + }); + + return await root_cmd.InvokeAsync(args); + } + + /// + /// The main logic of the tool. + /// + /// The connection string for the source backend. + /// The connection string for the destination backend. + /// Various options for the tool + /// The return code for the main entry; 0 on success. + private static async Task Run(Config config) + { + // Unpack and parse the multi token options + Dictionary global_options = config.GlobalOptions + .Select(x => x.Split("=")) + .ToDictionary(x => x[0], x => x[1]); + + // Parse the log level + var log_level_parsed = Enum.TryParse(config.LogLevel, true, out var log_level_enum); + log_level_enum = log_level_parsed ? log_level_enum : Duplicati.Library.Logging.LogMessageType.Information; + + using var console_sink = new Duplicati.CommandLine.ConsoleOutput(Console.Out, global_options); + using var multi_sink = new Duplicati.Library.Main.ControllerMultiLogTarget(console_sink, log_level_enum, null); + + // Parse the log file + // The log file sink doesn't have to be disposed, as the multi_sink will take care of it + Duplicati.Library.Logging.StreamLogDestination? log_file_sink = null; + if (!string.IsNullOrEmpty(config.LogFile)) + { + string log_file_dir = SystemIO.IO_OS.PathGetDirectoryName(config.LogFile); + if (!string.IsNullOrEmpty(log_file_dir) && !SystemIO.IO_OS.DirectoryExists(log_file_dir)) + SystemIO.IO_OS.DirectoryCreate(log_file_dir); + log_file_sink = new Duplicati.Library.Logging.StreamLogDestination(config.LogFile); + } + multi_sink.AddTarget(log_file_sink, log_level_enum, null); + + // Start the logging scope + using var _ = Duplicati.Library.Logging.Log.StartScope(multi_sink, log_level_enum); + + Dictionary src_opts = config.SrcOptions + .Select(x => x.Split("=")) + .ToDictionary(x => x[0], x => x[1]); + + Dictionary dst_opts = config.DstOptions + .Select(x => x.Split("=")) + .ToDictionary(x => x[0], x => x[1]); + + // Merge the global options into the source and destination options. The global options will be overridden by the source and destination options. + foreach (var x in global_options) + { + if (!src_opts.ContainsKey(x.Key)) + src_opts[x.Key] = x.Value; + if (!dst_opts.ContainsKey(x.Key)) + dst_opts[x.Key] = x.Value; + } + + // Check if we only had to parse the arguments + if (config.ParseArgumentsOnly) + { + Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync", "Arguments parsed successfully; exiting."); + return 0; + } + + // Load the backends + static Exception throw_message(string target) + { + var message = $"The {target} backend does not support streaming operations."; + var ex = new Exception(message); + Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "rsync", ex, message); + return ex; + } + + using var b1 = Duplicati.Library.DynamicLoader.BackendLoader.GetBackend(config.Src, src_opts); + var b1s = b1 as IStreamingBackend ?? throw throw_message("source"); + + using var b2 = Duplicati.Library.DynamicLoader.BackendLoader.GetBackend(config.Dst, dst_opts); + var b2s = b2 as IStreamingBackend ?? throw throw_message("destination"); + + // Prepare the operations + var (to_copy, to_delete, to_verify) = await PrepareFileLists(b1s, b2s, config, CancellationToken.None); + + // Verify the files if requested. If the files are not verified, they will be deleted and copied again. + long verified = 0, failed_verify = 0; + if (config.VerifyContents) + { + // As this is a potentially slow operation, ask for confirmation of the verification) + if (!config.Confirm) + { + Console.WriteLine($"This will verify {to_verify.Count()} files before copying them. Do you want to continue? [y/N]"); + var response = Console.ReadLine(); + if (!response?.Equals("y", StringComparison.CurrentCultureIgnoreCase) ?? true) + { + Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync", "Aborted"); + return -1; + } + } + + var not_verified = await VerifyAsync(b1s, b2s, to_verify, config); + failed_verify = not_verified.Count(); + verified = to_verify.Count() - failed_verify; + + if (not_verified.Any()) + { + Duplicati.Library.Logging.Log.WriteWarningMessage(LOGTAG, "rsync", null, + "{0} files failed verification. They will be deleted and copied again.", + not_verified.Count()); + to_delete = to_delete.Concat(not_verified); + to_copy = to_copy.Concat(not_verified); + } + } + + Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync", + "The remote synchronization plan is to {0} {1} files from {2}, then copy {3} files from {4} to {2}.", + config.Retention ? "rename" : "delete", + to_delete.Count(), b2s.DisplayName, to_copy.Count(), b1s.DisplayName); + + // As this is a potentially destructive operation, ask for confirmation + if (!config.Confirm) + { + var delete_rename = config.Retention ? "Rename" : "Delete"; + Console.WriteLine($"This will perform the following actions (in order):"); + Console.WriteLine($" - {delete_rename} {to_delete.Count()} files from {config.Dst}"); + Console.WriteLine($" - Copy {to_copy.Count()} files from {config.Src} to {config.Dst}"); + Console.WriteLine(); + Console.WriteLine("Do you want to continue? [y/N]"); + + var response = Console.ReadLine(); + if (!response?.Equals("y", StringComparison.CurrentCultureIgnoreCase) ?? true) + { + Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync", "Aborted"); + return -1; + } + } + + // Delete or rename the files that are not needed + long renamed = 0, deleted = 0; + if (config.Retention) + { + renamed = await RenameAsync(b2, to_delete, config); + Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync", + "Renamed {0} files in {1}", renamed, b2s.DisplayName); + } + else + { + deleted = await DeleteAsync(b2s, to_delete, config); + Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync", + "Deleted {0} files from {1}", deleted, b2s.DisplayName); + } + + // Copy the files + var (copied, copy_errors) = await CopyAsync(b1s, b2s, to_copy, config); + Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync", + "Copied {0} files from {1} to {2}", copied, b1s, b2s); + + // If there are still errors, retry a few times + if (copy_errors.Any()) + { + Duplicati.Library.Logging.Log.WriteWarningMessage(LOGTAG, "rsync", null, + "Could not copy {0} files.", copy_errors.Count()); + if (config.Retry > 0) + { + Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync", + "Retrying {0} more times to copy the {1} files that failed", + config.Retry, copy_errors.Count()); + for (int i = 0; i < config.Retry; i++) + { + await Task.Delay(5000); // Wait 5 seconds before retrying + (copied, copy_errors) = await CopyAsync(b1s, b2s, copy_errors, config); + Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync", + "Copied {0} files from {1} to {2}", copied, b1s, b2s); + if (!copy_errors.Any()) + break; + } + } + + if (copy_errors.Any()) + { + Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "rsync", null, + "Could not copy {0} files. Not retrying any more.", copy_errors.Count()); + return copy_errors.Count(); + } + } + + // Results reporting + if (verified > 0) + Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync", + "Verified {0} files in {1} that didn't need to be copied", + verified, b2s.DisplayName); + if (failed_verify > 0) + Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync", + "Failed to verify {0} files in {1}, which were then attempted to be copied", + failed_verify, b2s.DisplayName); + if (copied > 0) + Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync", + "Copied {0} files from {1} to {2}", copied, b1s.DisplayName, b2s.DisplayName); + if (deleted > 0) + Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync", + "Deleted {0} files from {1}", deleted, b2s.DisplayName); + if (renamed > 0) + Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync", + "Renamed {0} files in {1}", renamed, b2s.DisplayName); + + Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "rsync", + "Remote synchronization completed successfully"); + + return 0; + } + + // TODO have concurrency parameters: uploaders, downloaders + // TODO low memory mode, where things aren't kept in memory. Maybe utilize SQLite? + // TODO For convenience, have the option to launch a "duplicati test" on the destination backend after the synchronization + // TODO Save hash to minimize redownload + // TODO Duplicati Results + + /// + /// Copies the files from one backend to another. + /// The files are copied one by one, and each file is verified after uploading if the verify flag is set. + /// + /// The source backend. + /// The destination backend. + /// The files that will be copied. + /// The parsed configuration for the tool. + /// A tuple holding the number of succesful copies and a List of the files that failed. + private static async Task<(long, IEnumerable)> CopyAsync(IStreamingBackend b_src, IStreamingBackend b_dst, IEnumerable files, Config config) + { + long successful_copies = 0; + List errors = []; + using var s_src = new MemoryStream(); + using var s_dst = new MemoryStream(); + long i = 0, n = files.Count(); + var sw_get_src = new System.Diagnostics.Stopwatch(); + var sw_put_dst = new System.Diagnostics.Stopwatch(); + var sw_get_dst = new System.Diagnostics.Stopwatch(); + var sw_get_cmp = new System.Diagnostics.Stopwatch(); + + foreach (var f in files) + { + if (config.Progress) + Console.Write($"\rCopying: {i}/{n}"); + + Duplicati.Library.Logging.Log.WriteVerboseMessage(LOGTAG, "rsync", + "Copying {0} from {1} to {2}", f.Name, b_src.DisplayName, b_dst.DisplayName); + + try + { + sw_get_src.Start(); + await b_src.GetAsync(f.Name, s_src, CancellationToken.None); + sw_get_src.Stop(); + if (config.DryRun) + { + Duplicati.Library.Logging.Log.WriteDryrunMessage(LOGTAG, "rsync", + "Would write {0} bytes of {1} to {2}", + Duplicati.Library.Utility.Utility.FormatSizeString(s_src.Length), + f.Name, b_dst.DisplayName); + } + else + { + sw_put_dst.Start(); + await b_dst.PutAsync(f.Name, s_src, CancellationToken.None); + sw_put_dst.Stop(); + if (config.VerifyGetAfterPut) + { + sw_get_dst.Start(); + await b_dst.GetAsync(f.Name, s_dst, CancellationToken.None); + sw_get_dst.Stop(); + + sw_get_cmp.Start(); + string? err_string = null; + if (s_src.Length != s_dst.Length) + { + err_string = $"The sizes of the files do not match: {s_src.Length} != {s_dst.Length}."; + } + + if (!s_src.ToArray().SequenceEqual(s_dst.ToArray())) + { + err_string = (err_string is null ? "" : err_string + " ") + "The contents of the files do not match."; + } + sw_get_cmp.Stop(); + + if (err_string is not null) + { + throw new Exception(err_string); + } + } + } + + successful_copies++; + } + catch (Exception e) + { + Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "rsync", e, + "Error copying {0}: {1}", f.Name, e.Message); + errors.Add(f); + } + finally + { + s_src.SetLength(0); + s_dst.SetLength(0); + i++; + + // Stop any running timers + sw_get_src.Stop(); + sw_put_dst.Stop(); + sw_get_dst.Stop(); + sw_get_cmp.Stop(); + } + } + + if (config.Progress) + Console.WriteLine($"\rCopying: {n}/{n}"); + + Duplicati.Library.Logging.Log.WriteProfilingMessage(LOGTAG, "rsync", + "Copy | Get source: {0} ms, Put destination: {1} ms, Get destination: {2} ms, Get compare: {3} ms", + TimeSpan.FromMilliseconds(sw_get_src.ElapsedMilliseconds), + TimeSpan.FromMilliseconds(sw_put_dst.ElapsedMilliseconds), + TimeSpan.FromMilliseconds(sw_get_dst.ElapsedMilliseconds), + TimeSpan.FromMilliseconds(sw_get_cmp.ElapsedMilliseconds)); + + return (successful_copies, errors); + } + + /// + /// Deletes the files from a backend. + /// + /// The backend to delete the files from. + /// The files to delete. + /// The parsed configuration for the tool. + /// The number of successful deletions. + private static async Task DeleteAsync(IStreamingBackend b, IEnumerable files, Config config) + { + long successful_deletes = 0; + long i = 0, n = files.Count(); + using var timer = new Duplicati.Library.Logging.Timer(LOGTAG, "rsync", "Delete operation"); + + foreach (var f in files) + { + if (n > 1 && config.Progress) + { + Console.Write($"\rDeleting: {i}/{n}"); + } + + Duplicati.Library.Logging.Log.WriteVerboseMessage(LOGTAG, "rsync", + "Deleting {0} from {1}", f.Name, b.DisplayName); + + try + { + if (config.DryRun) + { + Duplicati.Library.Logging.Log.WriteDryrunMessage(LOGTAG, "rsync", + "Would delete {0} from {1}", f.Name, b.DisplayName); + } + else + { + await b.DeleteAsync(f.Name, CancellationToken.None); + } + successful_deletes++; + } + catch (Exception e) + { + Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "rsync", e, + "Error deleting {0}: {1}", f.Name, e.Message); + } + + i++; + } + + if (config.Progress) + Console.WriteLine($"\rDeleting: {n}/{n}"); + return successful_deletes; + } + + /// + /// Creates an option that allows multiple tokens and multiple arguments per token. + /// + /// The aliases for the option. + /// The description for the option. + /// The created option. + private static Option> OptionWithMultipleTokens(string[] aliases, string description, Func> getDefaultValue) + { + return new Option>(aliases: aliases, description: description, getDefaultValue: getDefaultValue) + { + Arity = ArgumentArity.OneOrMore, + AllowMultipleArgumentsPerToken = true + }; + } + + /// + /// Prepares the lists of files to copy, delete and verify. + /// The files to copy are the files that are not in the destination, have a different size or have a more recent modification date. + /// The files to delete are the files that are found in the destination but not found in the source. + /// The files to verify are the files that are found in both the source and the destination, and that have the same size and modification date. + /// + /// The source backend. + /// The destination backend. + /// The parsed configuration for the tool. + /// A tuple of Lists each holding the files to copy, delete and verify. + private static async Task<(IEnumerable, IEnumerable, IEnumerable)> PrepareFileLists(IStreamingBackend b_src, IStreamingBackend b_dst, Config config, CancellationToken cancelToken) + { + IEnumerable files_src, files_dst; + + using (new Duplicati.Library.Logging.Timer(LOGTAG, "rsync", "Prepare | List source")) + files_src = await b_src.ListAsync(cancelToken).ToListAsync(cancelToken).ConfigureAwait(false); + + using (new Duplicati.Library.Logging.Timer(LOGTAG, "rsync", "Prepare | List destination")) + files_dst = await b_dst.ListAsync(cancelToken).ToListAsync(cancelToken).ConfigureAwait(false); + + // Shortcut for force + if (config.Force) + { + return (files_src, files_dst, []); + } + + // Shortcut for empty destination + if (!files_dst.Any()) + { + return (files_src, [], []); + } + + Dictionary lookup_src, lookup_dst; + using (new Duplicati.Library.Logging.Timer(LOGTAG, "rsync", "Prepare | Build lookup for source and destination")) + { + lookup_src = files_src.ToDictionary(x => x.Name); + lookup_dst = files_dst.ToDictionary(x => x.Name); + } + + var to_copy = new List(); + var to_delete = new HashSet(); + var to_verify = new List(); + + // Find all of the files in src that are not in dst, where the dst has a different size than src or src a more recent modification date than dst + using (new Duplicati.Library.Logging.Timer(LOGTAG, "rsync", "Prepare | Check the files that are present in source against destination")) + foreach (var f_src in files_src) + { + if (lookup_dst.TryGetValue(f_src.Name, out var f_dst)) + { + if (f_src.Size != f_dst.Size || f_src.LastModification > f_dst.LastModification) + { + // The file is different, so we need to copy it + to_copy.Add(f_src); + to_delete.Add(f_dst.Name); + } + else + { + // The file seems to be the same, so we need to verify it if the user wants to + to_verify.Add(f_src); + } + } + else + { + // The file is not in the destination, so we need to copy it + to_copy.Add(f_src); + } + } + + // Find all of the files in dst that are not in src + using (new Duplicati.Library.Logging.Timer(LOGTAG, "rsync", "Prepare | Check the files that are present in destination against source")) + foreach (var f_dst in files_dst) + { + if (to_delete.Contains(f_dst.Name)) + continue; + + if (!lookup_src.ContainsKey(f_dst.Name)) + { + to_delete.Add(f_dst.Name); + } + } + + List to_delete_lookedup; + using (new Duplicati.Library.Logging.Timer(LOGTAG, "rsync", "Prepare | Lookup the files to delete")) + to_delete_lookedup = [.. to_delete.Select(x => lookup_dst[x])]; + + return (to_copy, to_delete_lookedup, to_verify); + } + + /// + /// Renames the files in a backend. + /// The renaming is done by deleting the file and re-uploading it with a new name. + /// + /// The backend to rename the files in. + /// The files to rename. + /// The parsed configuration for the tool. + /// The number of successful renames. + private static async Task RenameAsync(IBackend b, IEnumerable files, Config config) + { + long successful_renames = 0; + string prefix = $"{System.DateTime.UtcNow:yyyyMMddHHmmss}.old"; + using var downloaded = new MemoryStream(); + long i = 0, n = files.Count(); + + switch (b) + { + case IStreamingBackend sb: + { + var sw_get_src = new System.Diagnostics.Stopwatch(); + var sw_put_dst = new System.Diagnostics.Stopwatch(); + var sw_del_src = new System.Diagnostics.Stopwatch(); + + foreach (var f in files) + { + if (config.Progress) + Console.Write($"\rRenaming: {i}/{n}"); + + Duplicati.Library.Logging.Log.WriteVerboseMessage(LOGTAG, "rsync", + "Renaming {0} to {1}.{0} by deleting and re-uploading {2} bytes to {3}", + f.Name, prefix, + Duplicati.Library.Utility.Utility.FormatSizeString(downloaded.Length), + sb.DisplayName); + + try + { + sw_get_src.Start(); + await sb.GetAsync(f.Name, downloaded, CancellationToken.None); + sw_get_src.Stop(); + + if (config.DryRun) + { + Duplicati.Library.Logging.Log.WriteDryrunMessage(LOGTAG, "rsync", + "Would rename {0} to {1}.{0} by deleting and re-uploading {2} bytes to {3}", + f.Name, prefix, + Duplicati.Library.Utility.Utility.FormatSizeString(downloaded.Length), + sb.DisplayName); + } + else + { + sw_put_dst.Start(); + await sb.PutAsync($"{prefix}.{f.Name}", downloaded, CancellationToken.None); + sw_put_dst.Stop(); + sw_del_src.Start(); + await sb.DeleteAsync(f.Name, CancellationToken.None); + sw_del_src.Stop(); + } + successful_renames++; + } + catch (Exception e) + { + Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "rsync", e, + "Error renaming {0}: {1}", f.Name, e.Message); + } + finally + { + // Reset the stream + downloaded.SetLength(0); + + // Stop any running timers + sw_get_src.Stop(); + sw_put_dst.Stop(); + sw_del_src.Stop(); + } + + i++; + } + + Duplicati.Library.Logging.Log.WriteProfilingMessage(LOGTAG, "rsync", + "Rename | Get source: {0} ms, Put destination: {1} ms, Delete source: {2} ms", + TimeSpan.FromMilliseconds(sw_get_src.ElapsedMilliseconds), + TimeSpan.FromMilliseconds(sw_put_dst.ElapsedMilliseconds), + TimeSpan.FromMilliseconds(sw_del_src.ElapsedMilliseconds)); + + if (config.Progress) + Console.WriteLine($"\rRenaming: {n}/{n}"); + + return successful_renames; + } + case IRenameEnabledBackend rb: + { + var sw = new System.Diagnostics.Stopwatch(); + + foreach (var f in files) + { + if (config.Progress) + Console.Write($"\rRenaming: {i}/{n}"); + + Duplicati.Library.Logging.Log.WriteVerboseMessage(LOGTAG, "rsync", + "Renaming {0} to {1}.{0} by calling Rename on {2}", + f.Name, prefix, rb.DisplayName); + + try + { + if (config.DryRun) + { + Duplicati.Library.Logging.Log.WriteDryrunMessage(LOGTAG, "rsync", + "Would rename {0} to {1}.{0} by calling Rename on {2}", + f.Name, prefix, rb.DisplayName); + } + else + { + sw.Start(); + await rb.RenameAsync(f.Name, $"{prefix}.{f.Name}", CancellationToken.None); + sw.Stop(); + } + successful_renames++; + } + catch (Exception e) + { + Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "rsync", e, + "Error renaming {0}: {1}", f.Name, e.Message); + } + finally + { + // Ensure the timer is stopped + sw.Stop(); + } + + i++; + } + + Duplicati.Library.Logging.Log.WriteProfilingMessage(LOGTAG, "rsync", + "Rename: {0} ms", + TimeSpan.FromMilliseconds(sw.ElapsedMilliseconds)); + + if (config.Progress) + Console.WriteLine($"\rRenaming: {n}/{n}"); + + return successful_renames; + } + default: + throw new NotSupportedException("The backend does not support renaming"); + } + } + + /// + /// Verifies the files in the destination backend. + /// The verification is done by downloading the files from the destination backend and comparing them to the source files. + /// + /// The source backend. + /// The destination backend. + /// The files to verify. + /// The parsed configuration for the tool. + /// A list of the files that failed verification. + private static async Task> VerifyAsync(IStreamingBackend b_src, IStreamingBackend b_dst, IEnumerable files, Config config) + { + var errors = new List(); + using var s_src = new MemoryStream(); + using var s_dst = new MemoryStream(); + long i = 0, n = files.Count(); + var sw_get = new System.Diagnostics.Stopwatch(); + var sw_cmp = new System.Diagnostics.Stopwatch(); + + foreach (var f in files) + { + if (config.Progress) + Console.Write($"\rVerifying: {i}/{n}"); + + Duplicati.Library.Logging.Log.WriteVerboseMessage(LOGTAG, "rsync", + "Verifying {0} by downloading and comparing {1} bytes from {2} and {3}", + f.Name, + Duplicati.Library.Utility.Utility.FormatSizeString(s_src.Length), + b_dst.DisplayName, b_src.DisplayName); + + try + { + // Get both files + sw_get.Start(); + var fs = b_src.GetAsync(f.Name, s_src, CancellationToken.None); + var ds = b_dst.GetAsync(f.Name, s_dst, CancellationToken.None); + await Task.WhenAll(fs, ds); + sw_get.Stop(); + + // Compare the contents + sw_cmp.Start(); + if (s_src.Length != s_dst.Length || !s_src.ToArray().SequenceEqual(s_dst.ToArray())) + { + errors.Add(f); + } + sw_cmp.Stop(); + } + catch (Exception e) + { + errors.Add(f); + Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "rsync", e, + "Error during verification of {0}: {1}", f.Name, e.Message); + } + finally + { + // Reset the streams + s_src.SetLength(0); + s_dst.SetLength(0); + + // Stop any running timers + sw_get.Stop(); + sw_cmp.Stop(); + } + + i++; + } + + Duplicati.Library.Logging.Log.WriteProfilingMessage(LOGTAG, "rsync", + "Verify | Get: {0} ms, Compare: {1} ms", + TimeSpan.FromMilliseconds(sw_get.ElapsedMilliseconds), + TimeSpan.FromMilliseconds(sw_cmp.ElapsedMilliseconds)); + + if (config.Progress) + Console.WriteLine($"\rVerifying: {n}/{n}"); + + return errors; + } + + } +} diff --git a/Tools/RemoteSynchronization/RemoteSynchronization.csproj b/Tools/RemoteSynchronization/RemoteSynchronization.csproj new file mode 100644 index 000000000..9c800af07 --- /dev/null +++ b/Tools/RemoteSynchronization/RemoteSynchronization.csproj @@ -0,0 +1,16 @@ + + + + Library + net8.0 + disable + enable + + + + + + + + + diff --git a/Tools/TestDataGenerator/.gitignore b/Tools/TestDataGenerator/.gitignore new file mode 100644 index 000000000..1269488f7 --- /dev/null +++ b/Tools/TestDataGenerator/.gitignore @@ -0,0 +1 @@ +data diff --git a/Tools/TestDataGenerator/.vscode/launch.json b/Tools/TestDataGenerator/.vscode/launch.json new file mode 100644 index 000000000..b265cd745 --- /dev/null +++ b/Tools/TestDataGenerator/.vscode/launch.json @@ -0,0 +1,27 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Generate Test Data", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/bin/Debug/net8.0/TestDataGenerator.dll", + "args": ["create", "./data", "--file-count=1000", "--max-folder-count=100", "--max-fan-out=3"], + "cwd": "${workspaceFolder}", + "console": "internalConsole", + "stopAtEntry": false + }, + { + "name": "Update Test Data", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/bin/Debug/net8.0/TestDataGenerator.dll", + "args": ["update", "./data", "--new-files=10", "--updated-files=10", "--deleted-files=10"], + "cwd": "${workspaceFolder}", + "console": "internalConsole", + "stopAtEntry": false + } + ] +} \ No newline at end of file diff --git a/Tools/TestDataGenerator/.vscode/tasks.json b/Tools/TestDataGenerator/.vscode/tasks.json new file mode 100644 index 000000000..f2835a246 --- /dev/null +++ b/Tools/TestDataGenerator/.vscode/tasks.json @@ -0,0 +1,17 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/TestDataGenerator.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/Tools/TestDataGenerator/Commands/Create.cs b/Tools/TestDataGenerator/Commands/Create.cs new file mode 100644 index 000000000..967e6f7c3 --- /dev/null +++ b/Tools/TestDataGenerator/Commands/Create.cs @@ -0,0 +1,274 @@ +using System.Collections.Concurrent; +using System.CommandLine; +using System.CommandLine.NamingConventionBinder; +using static TestDataGenerator.Commands.Shared; + +namespace TestDataGenerator.Commands; + +/// +/// Create test data in a folder +/// +public static class Create +{ + /// + /// The input parameters for the command + /// + /// The folder to create files in + /// The number of files to create in the target folder + /// The maximum total size of the files to create in the target folder + /// The maximum size of each file to create in the target folder + /// The minimum size of each file to create in the target folder + /// The percentage of data that should be zeroed out in each file + /// The maximum number of subfolders to create in each folder + /// The maximum depth of subfolders to create in the target folder + /// The maximum number of folders to create in the target folder + /// The maximum length of each path segment in the target folder + /// The random seed to use for generating the test data + /// The number of parallel tasks to use for creating the test data + record CommandInput( + DirectoryInfo TargetFolder, + int FileCount, + long MaxTotalSize, + long MaxFileSize, + long MinFileSize, + int SparseFactor, + int MaxFanOut, + int MaxDepth, + int MaxFolderCount, + int MaxPathSegmentLength, + string RandomSeed, + int Parallel); + + /// + /// Creates the command + /// + /// The command + public static Command CreateCommand() + { + var command = new Command("create", "Create test data in a folder"); + + var targetFolderOption = new Argument("target-folder", "The folder to create files in"); + targetFolderOption.SetDefaultValue(new DirectoryInfo(Directory.GetCurrentDirectory())); + command.AddArgument(targetFolderOption); + + var fileCountOption = new Option("--file-count", "The number of files to create in the target folder"); + fileCountOption.SetDefaultValue(200000); + command.AddOption(fileCountOption); + + var maxTotalSizeOption = new Option("--max-total-size", "The maximum total size of the files to create in the target folder"); + maxTotalSizeOption.SetDefaultValue(1024 * 1024 * 1024L); + command.AddOption(maxTotalSizeOption); + + var maxFileSizeOption = new Option("--max-file-size", "The maximum size of each file to create in the target folder"); + maxFileSizeOption.SetDefaultValue(1024 * 1024L); + command.AddOption(maxFileSizeOption); + + var minFileSizeOption = new Option("--min-file-size", "The minimum size of each file to create in the target folder"); + minFileSizeOption.SetDefaultValue(0L); + command.AddOption(minFileSizeOption); + + var sparseFactorOption = new Option("--sparse-factor", "The percentage of data that should be zeroed out in each file"); + sparseFactorOption.SetDefaultValue(10); + command.AddOption(sparseFactorOption); + + var maxFanOutOption = new Option("--max-fan-out", "The maximum number of subfolders to create in each folder"); + maxFanOutOption.SetDefaultValue(10); + command.AddOption(maxFanOutOption); + + var maxDepthOption = new Option("--max-depth", "The maximum depth of subfolders to create in the target folder"); + maxDepthOption.SetDefaultValue(5); + command.AddOption(maxDepthOption); + + var maxFolderCountOption = new Option("--max-folder-count", "The maximum number of folders to create in the target folder"); + maxFolderCountOption.SetDefaultValue(10000); + command.AddOption(maxFolderCountOption); + + var maxPathSegmentLengthOption = new Option("--max-path-segment-length", "The maximum length of each path segment in the target folder"); + maxPathSegmentLengthOption.SetDefaultValue(15); + command.AddOption(maxPathSegmentLengthOption); + + var randomSeedOption = new Option("--random-seed", "The random seed to use for generating the test data"); + randomSeedOption.SetDefaultValue("Duplicati"); + command.AddOption(randomSeedOption); + + var parallelOption = new Option("--parallel", "The number of parallel tasks to use for creating the test data"); + parallelOption.SetDefaultValue(Environment.ProcessorCount); + command.AddOption(parallelOption); + + command.Handler = CommandHandler.Create(Execute); + + return command; + } + + /// + /// Executes the command + /// + /// The input parameters + private static void Execute(CommandInput input) + { + if (input.TargetFolder.Exists && input.TargetFolder.GetFileSystemInfos().Length > 0) + throw new Exception($"The target folder {input.TargetFolder.FullName} already exists"); + + if (input.MaxFileSize == 0) + throw new Exception("The maximum file size must be greater than zero"); + + if (input.MaxTotalSize == 0) + throw new Exception("The maximum total size must be greater than zero"); + + if (input.MinFileSize > input.MaxFileSize) + throw new Exception("The minimum file size must be less than or equal to the maximum file size"); + + if (input.FileCount == 0) + throw new Exception("The file count must be greater than zero"); + + if (input.MaxFolderCount == 0) + throw new Exception("The maximum folder count must be greater than zero"); + + if (input.MaxFanOut == 0) + throw new Exception("The maximum fan-out must be greater than zero"); + + if (input.MaxDepth == 0) + throw new Exception("The maximum depth must be greater than zero"); + + if (input.MaxPathSegmentLength == 0) + throw new Exception("The maximum path segment length must be greater than zero"); + + Console.WriteLine($"Creating test data in {input.TargetFolder.FullName}"); + var rnd = new Random(input.RandomSeed.GetHashCode()); + var folders = GeneratePathStructure(rnd, input.TargetFolder.FullName, input.MaxDepth, input.MaxFanOut, input.MaxPathSegmentLength); + while (folders.Count < input.MaxFolderCount) + { + folders.AddRange(GeneratePathStructure(rnd, input.TargetFolder.FullName, input.MaxDepth, input.MaxFanOut, input.MaxPathSegmentLength)); + folders = folders.Distinct().ToList(); + } + + while (folders.Count > input.MaxFolderCount) + folders.RemoveAt(folders.Count - 1); + + folders.Add(input.TargetFolder.FullName); + + var files = DistributeFiles(rnd, folders, GenerateFileNames(rnd, input.FileCount, input.MaxPathSegmentLength)); + + var filesWithSizes = files.Select(x => (x, rnd.NextInt64(input.MinFileSize, input.MaxFileSize))).ToList(); + var totalSize = filesWithSizes.Sum(x => x.Item2); + + while (totalSize > input.MaxTotalSize) + { + var index = rnd.Next(filesWithSizes.Count); + var newSize = rnd.NextInt64(input.MinFileSize, filesWithSizes[index].Item2); + totalSize -= filesWithSizes[index].Item2 - newSize; + filesWithSizes[index] = (filesWithSizes[index].Item1, newSize); + } + + Console.WriteLine($"Creating {folders.Count - 1} folders and {files.Count} files ({SizeToHumanReadable(totalSize)})"); + if (!input.TargetFolder.Exists) + input.TargetFolder.Create(); + + var lastUpdate = DateTime.UtcNow; + var foldersCreated = 0L; + + var updateInterval = TimeSpan.FromSeconds(10); + + foreach (var folder in folders) + { + Directory.CreateDirectory(folder); + foldersCreated++; + if ((DateTime.UtcNow - lastUpdate) > updateInterval) + { + Console.WriteLine($"Created {foldersCreated} of {folders.Count} folders"); + lastUpdate = DateTime.UtcNow; + } + } + + object stats_lock = new(); + var filesCreated = 0L; + var fileSizeCreated = 0L; + var generateStart = DateTime.UtcNow; + + var cancelToken = new CancellationTokenSource(); + var status_updater = Task.Run(async () => + { + while (!cancelToken.Token.IsCancellationRequested) + { + await Task.Delay((int)updateInterval.TotalMilliseconds, cancelToken.Token); + long local_filesCreated, local_fileSizeCreated; + lock (stats_lock) + { + local_filesCreated = filesCreated; + local_fileSizeCreated = fileSizeCreated; + } + + var generateNow = DateTime.UtcNow; + long throughput = (long)Math.Max(Math.Floor(local_fileSizeCreated / (generateNow - generateStart).TotalSeconds), 1); + var timeLeft = TimeSpan.FromSeconds((input.MaxTotalSize - local_fileSizeCreated) / throughput); + string timeLeftString = timeLeft.ToString(@"hh\:mm\:ss"); + Console.WriteLine($"Created {local_filesCreated} of {files.Count} files ({SizeToHumanReadable(local_fileSizeCreated)} of {SizeToHumanReadable(totalSize)}) ({SizeToHumanReadable(throughput)}/s) - ETA: {timeLeftString}"); + } + }, cancelToken.Token); + + Parallel.ForEach(Partitioner.Create(0, filesWithSizes.Count), new ParallelOptions { MaxDegreeOfParallelism = input.Parallel }, range => + { + var local_rnd = new Random(input.RandomSeed.GetHashCode() + range.Item1); + for (int i = range.Item1; i < range.Item2; i++) + { + var (file, size) = filesWithSizes[i]; + var filename = file; + try + { + // Sometimes with a large number of files, the file name clashes with a folder name. In that case, generate a new file name + for (int j = 0; j < 10 && Path.Exists(filename); j++) + filename = Path.Combine(Path.GetDirectoryName(filename) ?? "", "_" + Path.GetFileName(filename)); + + using var fs = new FileStream(filename, FileMode.Create, FileAccess.Write); + if (size > 0) + { + fs.SetLength(size); + fs.Position = 0; + + WriteRandomData(local_rnd, fs, size, input.SparseFactor); + } + + lock (stats_lock) + { + filesCreated++; + fileSizeCreated += size; + } + } + catch (Exception ex) + { + Console.WriteLine($"Error creating file {filename}: {ex.Message}"); + } + } + }); + + cancelToken.Cancel(); + var time_string = (DateTime.UtcNow - generateStart).ToString(@"hh\:mm\:ss"); + Console.WriteLine($"Created {filesCreated} of {files.Count} files ({SizeToHumanReadable(fileSizeCreated)} of {SizeToHumanReadable(totalSize)} at {SizeToHumanReadable((long)(fileSizeCreated / (DateTime.UtcNow - generateStart).TotalSeconds))}/s) in {time_string}"); + } + + /// + /// Generates a list of random folder paths + /// + /// The random number generator to use + /// The prefix to use for the folder paths + /// The maximum depth of the folder structure + /// The maximum number of subfolders to create in each folder + /// The maximum length of each path segment + private static List GeneratePathStructure(Random rnd, string prefix, int maxDepth, int maxFanOut, int maxSegmentLength) + { + var folders = new List(); + var folderCount = rnd.Next(1, maxFanOut + 1); + + if (maxDepth == 0) + return GeneratePathSegments(rnd, folderCount, maxSegmentLength).Select(x => Path.Combine(prefix, x)).ToList(); + + for (var i = 0; i < folderCount; i++) + { + var folderName = GetPathSegment(rnd, maxSegmentLength); + folders.Add(Path.Combine(prefix, folderName)); + folders.AddRange(GeneratePathStructure(rnd, Path.Combine(prefix, folderName), maxDepth - 1, maxFanOut, maxSegmentLength)); + } + + return folders; + } +} diff --git a/Tools/TestDataGenerator/Commands/Shared.cs b/Tools/TestDataGenerator/Commands/Shared.cs new file mode 100644 index 000000000..caf3e614b --- /dev/null +++ b/Tools/TestDataGenerator/Commands/Shared.cs @@ -0,0 +1,142 @@ +using System.Text; + +namespace TestDataGenerator.Commands; + +/// +/// Shared utility methods for the test data generator +/// +public static class Shared +{ + /// + /// Distributes the files in the list to the folders in the list + /// + /// The random number generator to use + /// The list of folders to distribute the files to + /// The list of files to distribute + /// The list of files distributed to the folders + public static List DistributeFiles(Random rnd, List folders, List list) + { + var expanded = new List(); + foreach (var file in list) + expanded.Add(Path.Combine(folders[rnd.Next(folders.Count)], file)); + + return expanded; + } + + /// + /// Converts a size in bytes to a human-readable string + /// + /// The size in bytes + /// The human-readable string + public static string SizeToHumanReadable(long size) + { + if (size < 1024) + return $"{size} B"; + if (size < 1024 * 1000) + return $"{size / 1024m:F2} KiB"; + if (size < 1024 * 1024 * 1000) + return $"{size / 1024 / 1024m:F2} MiB"; + if (size < 1024L * 1024 * 1024 * 1000) + return $"{size / 1024 / 1024 / 1024m:F2} GiB"; + return $"{size / 1024 / 1024 / 1024 / 1024m:F2} TiB"; + } + + /// + /// The list of file extensions to use for random file names + /// + private static readonly IReadOnlyList FileExtensions = new List([".bin", ".dat", ".binary", ".data", ".test", ""]); + + /// + /// The characters to use for random path segments + /// + private const string PathSegmentChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + /// + /// Generates a random path segment + /// + /// The random number generator to use + /// The maximum length of the path segment + /// The random path segment + public static string GetPathSegment(Random rnd, int maxLength) + { + var length = rnd.Next(1, maxLength + 1); + var sb = new StringBuilder(length); + for (int j = 0; j < length; j++) + sb.Append(PathSegmentChars[rnd.Next(PathSegmentChars.Length)]); + return sb.ToString(); + } + + /// + /// Generates a list of random path segments + /// + /// The random number generator to use + /// The number of path segments to generate + /// The maximum length of each path segment + /// The list of path segments + public static List GeneratePathSegments(Random rnd, int count, int maxLength) + { + var names = new List(count); + for (int i = 0; i < count; i++) + names.Add(GetPathSegment(rnd, maxLength)); + return names; + } + + /// + /// Generates a random file name + /// + /// The random number generator to use + /// The maximum length of the file name + public static string GenerateFileName(Random rnd, int maxLength) + { + return GetPathSegment(rnd, maxLength) + FileExtensions[rnd.Next(FileExtensions.Count)]; + } + + /// + /// Generates a list of random file names + /// + /// The random number generator to use + /// The number of file names to generate + /// The maximum length of each file name + /// The list of file names + public static List GenerateFileNames(Random rnd, int count, int maxLength) + { + var names = new List(count); + for (int i = 0; i < count; i++) + names.Add(GenerateFileName(rnd, maxLength)); + return names; + } + + /// + /// Writes random data to a stream + /// + /// The random number generator to use + /// The stream to write to + /// The size of the data to write + /// The factor to use for sparse data + /// + /// The sparse factor is a percentage value that determines how many bytes are skipped in the stream. + /// For example, a sparse factor of 10 means that 10% of the data will be not be overwritten. + /// + public static void WriteRandomData(Random rnd, Stream fs, long size, int sparseFactor) + { + var buffer = new byte[4096]; + while (size > 0) + { + var chunkSize = rnd.Next(1, (int)Math.Min(size, buffer.Length)); + var sp = buffer.AsSpan(chunkSize); + if (rnd.Next(100) < sparseFactor) + { + // Skip the bytes + fs.Position += sp.Length; + } + else + { + rnd.NextBytes(sp); + fs.Write(sp); + } + + size -= sp.Length; + } + } + +} \ No newline at end of file diff --git a/Tools/TestDataGenerator/Commands/Update.cs b/Tools/TestDataGenerator/Commands/Update.cs new file mode 100644 index 000000000..5066ac50e --- /dev/null +++ b/Tools/TestDataGenerator/Commands/Update.cs @@ -0,0 +1,195 @@ +using System.CommandLine; +using System.CommandLine.NamingConventionBinder; +using static TestDataGenerator.Commands.Shared; + +namespace TestDataGenerator.Commands; + +/// +/// Update test data in a folder +/// +public static class Update +{ + /// + /// The input parameters for the command + /// + /// The folder to update files in + /// The number of new files to create in the target folder + /// The number of files to update in the target folder + /// The number of files to delete in the target folder + /// The number of files to rename in the target folder + /// The maximum size of each file to create in the target folder + /// The minimum size of each file to create in the target folder + /// The percentage of data that should be zeroed out in each file + /// The percentage of files to update in the target folder + /// The maximum length of each path segment in the target folder + record CommandInput( + DirectoryInfo TargetFolder, + int NewFiles, + int UpdatedFiles, + int DeletedFiles, + int RenameFiles, + long MaxFileSize, + long MinFileSize, + int SparseFactor, + int UpdateFactor, + int MaxPathSegmentLength); + + /// + /// Creates the command + /// + /// The command + public static Command CreateCommand() + { + var command = new Command("update", "Update test data in a folder"); + + var targetFolderOption = new Argument("target-folder", "The folder to update files in"); + targetFolderOption.SetDefaultValue(new DirectoryInfo(Directory.GetCurrentDirectory())); + command.AddArgument(targetFolderOption); + + var newFilesOption = new Option("--new-files", "The number of new files to create in the target folder"); + newFilesOption.SetDefaultValue(1000); + command.AddOption(newFilesOption); + + var updatedFilesOption = new Option("--updated-files", "The number of files to update in the target folder"); + updatedFilesOption.SetDefaultValue(1000); + command.AddOption(updatedFilesOption); + + var deletedFilesOption = new Option("--deleted-files", "The number of files to delete in the target folder"); + deletedFilesOption.SetDefaultValue(1000); + command.AddOption(deletedFilesOption); + + var renameFilesOption = new Option("--rename-files", "The number of files to rename in the target folder"); + renameFilesOption.SetDefaultValue(1000); + command.AddOption(renameFilesOption); + + var maxFileSizeOption = new Option("--max-file-size", "The maximum size of each file to create in the target folder"); + maxFileSizeOption.SetDefaultValue(1024 * 1024L); + command.AddOption(maxFileSizeOption); + + var minFileSizeOption = new Option("--min-file-size", "The minimum size of each file to create in the target folder"); + minFileSizeOption.SetDefaultValue(1024L); + command.AddOption(minFileSizeOption); + + var sparseFactorOption = new Option("--sparse-factor", "The percentage of data that should be zeroed out in each file"); + sparseFactorOption.SetDefaultValue(10); + command.AddOption(sparseFactorOption); + + var updateFactorOption = new Option("--update-factor", "The percentage of files to update in the target folder"); + updateFactorOption.SetDefaultValue(10); + command.AddOption(updateFactorOption); + + var maxPathSegmentLengthOption = new Option("--max-path-segment-length", "The maximum length of each path segment in the target folder"); + maxPathSegmentLengthOption.SetDefaultValue(15); + command.AddOption(maxPathSegmentLengthOption); + + command.Handler = CommandHandler.Create(Execute); + + return command; + } + + /// + /// Executes the command + /// + /// The input parameters + private static void Execute(CommandInput input) + { + if (!input.TargetFolder.Exists) + throw new DirectoryNotFoundException($"The target folder {input.TargetFolder.FullName} does not exist"); + + if (input.MaxFileSize == 0) + throw new Exception("The maximum file size must be greater than zero"); + + Console.WriteLine($"Updating test data in {input.TargetFolder.FullName}"); + + var folders = new List(); + var files = new List(); + + foreach (var folder in input.TargetFolder.EnumerateDirectories("*", SearchOption.AllDirectories)) + { + folders.Add(folder.FullName); + foreach (var file in folder.EnumerateFiles()) + files.Add(file.FullName); + } + + Console.WriteLine($"Found {folders.Count} folders and {files.Count} files"); + + folders.Add(input.TargetFolder.FullName); + + var rnd = new Random(); + var newFileNames = DistributeFiles(rnd, folders, GenerateFileNames(rnd, input.NewFiles, input.MaxPathSegmentLength)); + + foreach (var file in newFileNames) + { + Console.WriteLine($"Creating new file {file}"); + + var size = rnd.NextInt64((int)input.MinFileSize, (int)input.MaxFileSize); + using var fs = new FileStream(file, FileMode.Create, FileAccess.Write); + fs.SetLength(size); + if (size > 0) + { + fs.Position = 0; + WriteRandomData(rnd, fs, size, input.SparseFactor); + } + } + + var updatedFileNames = new HashSet(); + while (updatedFileNames.Count < input.UpdatedFiles) + { + var file = files[rnd.Next(files.Count)]; + if (!updatedFileNames.Contains(file)) + updatedFileNames.Add(file); + + if (updatedFileNames.Count == files.Count) + break; + } + + foreach (var file in updatedFileNames) + { + Console.WriteLine($"Updating file {file}"); + + var size = rnd.NextInt64(input.MinFileSize, input.MaxFileSize); + using var fs = new FileStream(file, FileMode.Open, FileAccess.Write); + fs.SetLength(size); + if (size > 0) + { + fs.Position = 0; + WriteRandomData(rnd, fs, size, 100 - input.UpdateFactor); + } + } + + var deletedFileNames = new HashSet(); + while (deletedFileNames.Count < input.DeletedFiles) + { + var file = files[rnd.Next(files.Count)]; + if (!deletedFileNames.Contains(file)) + deletedFileNames.Add(file); + + if (deletedFileNames.Count == files.Count) + break; + } + + foreach (var file in deletedFileNames) + { + Console.WriteLine($"Deleting file {file}"); + File.Delete(file); + } + + var renamedFileNames = new HashSet(); + while (renamedFileNames.Count < input.RenameFiles) + { + var file = files[rnd.Next(files.Count)]; + if (!renamedFileNames.Contains(file)) + renamedFileNames.Add(file); + + if (renamedFileNames.Count == files.Count) + break; + } + + foreach (var file in renamedFileNames) + { + var newName = Path.Combine(folders[rnd.Next(folders.Count)], GenerateFileNames(rnd, 1, input.MaxPathSegmentLength).First()); + Console.WriteLine($"Renaming file {file} to {newName}"); + File.Move(file, newName); + } + } +} diff --git a/Tools/TestDataGenerator/Program.cs b/Tools/TestDataGenerator/Program.cs new file mode 100644 index 000000000..6f43b554d --- /dev/null +++ b/Tools/TestDataGenerator/Program.cs @@ -0,0 +1,10 @@ +using System.CommandLine; + +var rootCommand = new RootCommand("Test Data Generator") +{ + TestDataGenerator.Commands.Create.CreateCommand(), + TestDataGenerator.Commands.Update.CreateCommand() +}; + +return rootCommand.Invoke(args); + diff --git a/Tools/TestDataGenerator/README.md b/Tools/TestDataGenerator/README.md new file mode 100644 index 000000000..f01ba7e88 --- /dev/null +++ b/Tools/TestDataGenerator/README.md @@ -0,0 +1,7 @@ +# Test data generator + +This tool is intended for use by developer who need to generate a data set for testing Duplicati. + +The tool can create a set of random data at a target folder using the `create` command, and make alterations using the `update` command. + +Use the `help` command to examine the supported properties and adjust to make the desired test dataset. diff --git a/Tools/TestDataGenerator/TestDataGenerator.csproj b/Tools/TestDataGenerator/TestDataGenerator.csproj new file mode 100644 index 000000000..04c2e9be8 --- /dev/null +++ b/Tools/TestDataGenerator/TestDataGenerator.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + diff --git a/Tools/TestDataGenerator/TestDataGenerator.sln b/Tools/TestDataGenerator/TestDataGenerator.sln new file mode 100644 index 000000000..6cbfc34b3 --- /dev/null +++ b/Tools/TestDataGenerator/TestDataGenerator.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestDataGenerator", "TestDataGenerator.csproj", "{547C0549-040B-4F4F-A410-351FC0176F28}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {547C0549-040B-4F4F-A410-351FC0176F28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {547C0549-040B-4F4F-A410-351FC0176F28}.Debug|Any CPU.Build.0 = Debug|Any CPU + {547C0549-040B-4F4F-A410-351FC0176F28}.Release|Any CPU.ActiveCfg = Release|Any CPU + {547C0549-040B-4F4F-A410-351FC0176F28}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {F3FB6242-FFDF-42A7-A78F-BB1FE67C12D8} + EndGlobalSection +EndGlobal diff --git a/changelog.txt b/changelog.txt index 752a6c20e..cbb55c69f 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,188 @@ +2025-03-15 - 2.1.0.111_canary_2025-03-15 +========== +This release is a canary release intended to be used for testing. + +# Major changes in this version + +- Restore improvements +This version has a few tweaks to the new restore flow that makes it more robust. + +- Improved validation +To safeguard against internal errors, there is now a more strict validation before performing cleanup. +The new logic checks if the previous operation failed and will only automatically repair the remote if this is the case. +The repair command bypasses this check and can repair the remote regardless. + +If the previous run was fully completed, any leftover remote files are now treated as an error. +Additionally, operations that does not change remote state (list, test, restore, etc) will not repair the remote state, but just verify that it is working for the requested operation. + +- Updates to ngclient +Multiple updates to ngclient: improved auth flow, searchable menus, Windows filesystem support, restore from configuration, and more. + +# Potentially breaking changes (from 2.1.0.109) + - Location of data folder has changed in some cases, refer to previous release for details + - Permissions are now applied to the data folder, refer to previous release for details + - New user interface, `ngclient` is the default; there is a button to switch between the two + +## Detailed list of changes: +- Reduced number of samples taken for testing +- Improved restore flow +- Improved error handling for Azure Blob storage +- Improved help text for remote synchronization tool +- Implemented more rigorous validation and safeguards for failed backups +- Better support for IPv6 and unmapped localhost scenarios +- Added check to avoid block sizes that are the same or less than volume size +- Introduced more async calls for better thread usage in handlers +- Added forced culture for SQL strings and simpler transaction logic +- Added Arm v7 Agent builds +- Improved certificate validation to check expiration and support root or intermediate certificate hashes + +2025-02-28 - 2.1.0.110_canary_2025-02-28 +========== +This release is a canary release intended to be used for testing. + +# Major changes in this version + +- Restore improvements +Building further on the new restore engine, this version has significant performance speedups during restore. + +- New signing keys +The builds are now signed by "Duplicati Inc" for MacOS and Windows. +The Windows signatures are now done with an extended validation (EV) certificate and should not trigger Windows Defender warnings. + +- New enumeration logic +The enumeration of files and folders has been rewritten to better support some edge cases. + +- Improved pagination support +All backends are updated to support the new list methods that supports pagination and cancellation. +The test method will now only load a single page of results, instead of listing all items. + +- Support for backend-as-source +This version has support for backups of both remote and local data. +In this version, S3, IDrive, SSH and CIFS sources are supported. +The UI does not yet support this, but it is possible to read data from a remote location by "mounting" it into the local filesystem. +For the commandline (and manual text entry in the UI) enter sources such as: +``` +// Linux/MacOS +@/mnt/s3-data|s3://example?auth-username=... + +// Windows +@X:\server1|cifs://server/share?auth-username=... +``` + +# Potentially breaking changes (from 2.1.0.109) + - Location of data folder has changed in some cases, refer to previous release for details + - Permissions are now applied to the data folder, refer to previous release for details + - New user interface, `ngclient` is the default; there is a button to switch between the two + +## Detailed list of changes: +- Improved script examples +- Fixed double-click TrayIcon on Windows, thanks @Alex-Heard +- Fixed an issue with CORS headers +- Parallel downloads for restore +- Report interface and port in Webserver startup message +- Fixed missing example script on Windows +- Fixed a case where a leading slash could be stripped from the path or a URL +- Added additional Infomaniak endpoints, thanks @ochaze +- Backend listing is now async for better pagination and cancellation support +- Updated signing support for the release builds +- Added a link from `ngax` to `ngclient` (the other way already exists) +- Added alternate source providers and new enumeration process +- Performance optimizations for restore process + +2025-02-11 - 2.1.0.109_canary_2025-02-11 +========== +This release is a canary release intended to be used for testing. + +# Important change for Windows Service +This build changes the folder locations for the `Duplicati-server.sqlite` file on Windows and Linux when running as a service. +There is support for backwards compatibility, so existing installations should not be affected. + +The detailed changes are: +Windows: Avoid storing data in `C:\Windows\System32\config\systemprofile\AppData\Local\Duplicati` and prefer `{CSIDL_COMMON_APPDATA}\Duplicati` +Linux: Avoid storing data in `/Duplicati` and prefer `/var/lib/Duplicati`. + +If you are not running Duplicati as a service, this has no effect. +If you are using `--server-datafolder` or `DUPLICATI_HOME`, this has no effect on the database, but may cause your machineid and installid to change. + +The `machineid.txt` and `installid.txt` would previously be stored in the local app data folder, even when using portable mode or choosing a specific data folder. +This has been fixed, so the files will now follow the database. +If you are using the Duplicati console or otherwise depend on these values, you need to move them into the folder where the database is stored. + +## Important for users running 2.1.0.108 +For **2.1.0.108 version only**, the service folder was `{SYSTEMDRIVE}\Users\LocalService\Duplicati` which was dropped. +If you have only installed 2.1.0.108 and ended up using the previous folder, you must manually move it to `{CSIDL_COMMON_APPDATA}\Duplicati`. + +# Permissions on database and data folder +This update sets permissions on the data folder and the databases to prevent unauthorized access from local accounts. +To opt out of setting permissions on each startup, place a file named `insecure-permissions.txt` inside the data folder. + +# New user interface +This version uses the `ngclient` as the default interface. If some feature is not working as expected, it is possible to switch back to the `ngax` client. +To switch client, replace `ngclient` with `ngax` in the url, or use the `theme.html` page to choose the UI (example: `http://localhost:8200/theme.html`). +Please report any issues with the new UI. + +# Removed timeout guard +Due to many reports of failures with the timeout mechanism this has been removed in the current version. It will be added back into each of the backends later. + +# Added a new tool for offsite backup +This release features a new tool: `Duplicati.CommandLine.SyncTool.exe` / `duplicati-sync-tool` which can be used to create copies of backup files in multiple locations. The tool is currently documented with the built-in help, and more documentation is on the way. The tool is intended to be more integrated into Duplicati later and enables a straightforward way to achieve 3-2-1 backups, including copies in immutable storage. + +## Detailed list of changes: +- Support topics on Telegram reports +- Added remote syncronization tool +- Removed high-level backend timeout +- Added guard to prevent dataloss on shared destinations +- Added option to disable automatic update checks +- Fixed a bug with the new restore that could cause incorrect files to be restored if files alredy exists +- Fixed a case where updated timestamps were discarded if no data was changed +- Added permission updates to data folder +- Fixed automatic fallback on unsupported Zip files +- Fixed multiple issues with TrayIcon state +- Mark Mega.nz backend as deprecated +- Switched to ngclient as the default UI +- Added support for 429 responses on B2 + +2025-01-31 - 2.1.0.108_canary_2025-01-31 +========== +This release is a canary release intended to be used for testing. + +# Important change +This build changes the folder locations for the `Duplicati-server.sqlite` file on Windows and Linux when running as a service. +There is support for backwards compatibility, so existing installations should not be affected. + +The detailed changes are: +Windows: Avoid storing data in `C:\Windows\System32\config\systemprofile\AppData\Local\Duplicati` and prefer `{SystemRoot}\Users\LocalService\Duplicati` +Linux: Avoid storing data in `/Duplicati` and prefer `/var/lib/Duplicati`. + +If you are not running Duplicati as a service, this has no effect. +If you are using `--server-datafolder` or `DUPLICATI_HOME`, this has no effect on the database, but may cause your machineid and installid to change. + +The `machineid.txt` and `installid.txt` would previously be stored in the local app data folder, even when using portable mode or choosing a specific data folder. +This has been fixed, so the files will now follow the database. +If you are using the Duplicati console or otherwise depend on these values, you need to move them into the folder where the database is stored. + +# New UI +This release contains an updated version of the new UI. +In the url, change `ngax` to `ngclient` to try the new UI. + +## Detailed list of changes: +- Fixed encoding of exported files +- Fixed issue with restore trying to read files opened with write-only +- Improved backend tester +- Missing file detection during recreate is now a warning +- Fixed Minio backend not throwing exceptions +- Fixed hidden files display in source picker +- Added additional source folders for MacOS +- Removed SHA1 signatures on Windows signing +- Rewrote the backend manager to better control backend (re-)use +- Updated reporting to support sending OS type and backend type +- Tagging Docker images with channel, thanks @stavros-k +- Added support for CORS headers in the Webserver +- Unified help arguments across all executables +- Fixed parsing flags as multiple values for FTP SSL type +- Added guard against storing files in Windows folder +- Fixed crash in AutoUpdater + 2025-01-17 - 2.1.0.107_canary_2025-01-17 ========== This release is a canary release intended to be used for testing. diff --git a/thirdparty/SharpAESCrypt/License.txt b/thirdparty/SharpAESCrypt/License.txt index 09905eb1a..4ac9a5e9c 100755 --- a/thirdparty/SharpAESCrypt/License.txt +++ b/thirdparty/SharpAESCrypt/License.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Duplicati +Copyright (c) 2025 Duplicati Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal