diff --git a/Duplicati/CommandLine/BackendTester/Program.cs b/Duplicati/CommandLine/BackendTester/Program.cs index c0a19766f..0bb0f5c07 100644 --- a/Duplicati/CommandLine/BackendTester/Program.cs +++ b/Duplicati/CommandLine/BackendTester/Program.cs @@ -176,7 +176,7 @@ namespace Duplicati.CommandLine.BackendTester IEnumerable curlist = null; try { - Retry(() => backend.TestAsync(CancellationToken.None), retries).Await(); + Retry(() => backend.TestAsync(true, CancellationToken.None), retries).Await(); curlist = Retry(() => backend.ListAsync(CancellationToken.None).ToBlockingEnumerable().ToList(), retries); } catch (FolderMissingException) diff --git a/Duplicati/CommandLine/SourceTool/ListEnumerator.cs b/Duplicati/CommandLine/SourceTool/ListEnumerator.cs index b267cf5eb..e186f6bbb 100644 --- a/Duplicati/CommandLine/SourceTool/ListEnumerator.cs +++ b/Duplicati/CommandLine/SourceTool/ListEnumerator.cs @@ -38,7 +38,7 @@ public static partial class Common 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)) + await foreach (var item in source.EnumerateAsync(token)) visit.Push((item, 0)); while (visit.Count() != 0) diff --git a/Duplicati/Library/Backend/AliyunOSS/AliyunOSSBackend.cs b/Duplicati/Library/Backend/AliyunOSS/AliyunOSSBackend.cs index 3d8b59b77..ef5492de2 100644 --- a/Duplicati/Library/Backend/AliyunOSS/AliyunOSSBackend.cs +++ b/Duplicati/Library/Backend/AliyunOSS/AliyunOSSBackend.cs @@ -175,8 +175,8 @@ namespace Duplicati.Library.Backend.AliyunOSS } } - public Task TestAsync(CancellationToken cancelToken) - => this.TestReadWritePermissionsAsync(cancelToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) + => this.TestBackendAsync(alsoWrite, cancelToken); public Task CreateFolderAsync(CancellationToken cancelToken) // No need to create folders diff --git a/Duplicati/Library/Backend/AzureBlob/AzureBlobBackend.cs b/Duplicati/Library/Backend/AzureBlob/AzureBlobBackend.cs index 51e175886..4c8dde038 100644 --- a/Duplicati/Library/Backend/AzureBlob/AzureBlobBackend.cs +++ b/Duplicati/Library/Backend/AzureBlob/AzureBlobBackend.cs @@ -256,8 +256,8 @@ namespace Duplicati.Library.Backend.AzureBlob public Task GetDNSNamesAsync(CancellationToken cancelToken) => Task.FromResult(_azureBlob.DnsNames); - public Task TestAsync(CancellationToken cancellationToken) - => this.TestReadWritePermissionsAsync(cancellationToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancellationToken) + => this.TestBackendAsync(alsoWrite, cancellationToken); public Task CreateFolderAsync(CancellationToken cancellationToken) { diff --git a/Duplicati/Library/Backend/Backblaze/B2.cs b/Duplicati/Library/Backend/Backblaze/B2.cs index 8122c14bf..3cf715c35 100644 --- a/Duplicati/Library/Backend/Backblaze/B2.cs +++ b/Duplicati/Library/Backend/Backblaze/B2.cs @@ -657,8 +657,8 @@ public class B2 : IStreamingBackend, ILockingBackend, IRenameEnabledBackend /// Performs test with backend (internally it uses the List() command, which by definition means the backend is working) /// /// Cancellation Token - public Task TestAsync(CancellationToken cancelToken) - => this.TestReadWritePermissionsAsync(cancelToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) + => this.TestBackendAsync(alsoWrite, cancelToken); /// /// Create remote folder diff --git a/Duplicati/Library/Backend/Box/BoxBackend.cs b/Duplicati/Library/Backend/Box/BoxBackend.cs index 4b0507efe..188e4c057 100644 --- a/Duplicati/Library/Backend/Box/BoxBackend.cs +++ b/Duplicati/Library/Backend/Box/BoxBackend.cs @@ -452,8 +452,8 @@ namespace Duplicati.Library.Backend.Box } /// - public Task TestAsync(CancellationToken cancelToken) - => this.TestReadWritePermissionsAsync(cancelToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) + => this.TestBackendAsync(alsoWrite, cancelToken); /// public Task CreateFolderAsync(CancellationToken cancellationToken) diff --git a/Duplicati/Library/Backend/DrimeCloud/DrimeBackend.cs b/Duplicati/Library/Backend/DrimeCloud/DrimeBackend.cs index db8ea493a..7ea33427f 100644 --- a/Duplicati/Library/Backend/DrimeCloud/DrimeBackend.cs +++ b/Duplicati/Library/Backend/DrimeCloud/DrimeBackend.cs @@ -505,8 +505,8 @@ public class DrimeBackend : IBackend, IStreamingBackend //, IRenameEnabledBacken } /// - public Task TestAsync(CancellationToken cancelToken) - => this.TestReadWritePermissionsAsync(cancelToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) + => this.TestBackendAsync(alsoWrite, cancelToken); /// public Task GetDNSNamesAsync(CancellationToken cancelToken) diff --git a/Duplicati/Library/Backend/Dropbox/Dropbox.cs b/Duplicati/Library/Backend/Dropbox/Dropbox.cs index 5332b0235..df11e5fee 100644 --- a/Duplicati/Library/Backend/Dropbox/Dropbox.cs +++ b/Duplicati/Library/Backend/Dropbox/Dropbox.cs @@ -214,8 +214,8 @@ namespace Duplicati.Library.Backend public Task GetDNSNamesAsync(CancellationToken cancelToken) => Task.FromResult(WebApi.Dropbox.Hosts()); - public Task TestAsync(CancellationToken cancelToken) - => this.TestReadWritePermissionsAsync(cancelToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) + => this.TestBackendAsync(alsoWrite, cancelToken); public async Task CreateFolderAsync(CancellationToken cancelToken) { diff --git a/Duplicati/Library/Backend/Duplicati/DuplicatiBackend.cs b/Duplicati/Library/Backend/Duplicati/DuplicatiBackend.cs index 36b1229e2..fcbf9b083 100644 --- a/Duplicati/Library/Backend/Duplicati/DuplicatiBackend.cs +++ b/Duplicati/Library/Backend/Duplicati/DuplicatiBackend.cs @@ -478,8 +478,8 @@ public class DuplicatiBackend : IBackend, IStreamingBackend, IQuotaEnabledBacken ]; /// - public Task TestAsync(CancellationToken cancelToken) - => this.TestReadWritePermissionsAsync(cancelToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) + => this.TestBackendAsync(alsoWrite, cancelToken); /// /// Response from getting credentials diff --git a/Duplicati/Library/Backend/FTP/FTPBackend.cs b/Duplicati/Library/Backend/FTP/FTPBackend.cs index c7fe6cb08..ed5336b6b 100644 --- a/Duplicati/Library/Backend/FTP/FTPBackend.cs +++ b/Duplicati/Library/Backend/FTP/FTPBackend.cs @@ -515,7 +515,7 @@ namespace Duplicati.Library.Backend public bool SupportsStreaming => true; /// - public async Task TestAsync(CancellationToken cancellationToken) + public async Task TestAsync(bool alsoWrite, CancellationToken cancellationToken) { // Start with a simple list and pureFTP detection try @@ -556,7 +556,8 @@ namespace Duplicati.Library.Backend } // Test the read/write permissions in folder - await this.TestReadWritePermissionsAsync(cancellationToken).ConfigureAwait(false); + if (alsoWrite) + await this.TestReadWritePermissionsAsync(cancellationToken).ConfigureAwait(false); } /// diff --git a/Duplicati/Library/Backend/File/FileBackend.cs b/Duplicati/Library/Backend/File/FileBackend.cs index 65765fe34..23fadb976 100644 --- a/Duplicati/Library/Backend/File/FileBackend.cs +++ b/Duplicati/Library/Backend/File/FileBackend.cs @@ -410,8 +410,8 @@ namespace Duplicati.Library.Backend public string Description => Strings.FileBackend.Description; /// - public Task TestAsync(CancellationToken cancelToken) - => this.TestReadWritePermissionsAsync(cancelToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) + => this.TestBackendAsync(alsoWrite, cancelToken); /// public async Task CreateFolderAsync(CancellationToken cancelToken) diff --git a/Duplicati/Library/Backend/Filejump/Filejump.cs b/Duplicati/Library/Backend/Filejump/Filejump.cs index 12b83b34d..bcbc2924c 100644 --- a/Duplicati/Library/Backend/Filejump/Filejump.cs +++ b/Duplicati/Library/Backend/Filejump/Filejump.cs @@ -517,8 +517,8 @@ namespace Duplicati.Library.Backend } /// - public Task TestAsync(CancellationToken token) - => this.TestReadWritePermissionsAsync(token); + public Task TestAsync(bool alsoWrite, CancellationToken token) + => this.TestBackendAsync(alsoWrite, token); /// public Task GetDNSNamesAsync(CancellationToken token) diff --git a/Duplicati/Library/Backend/Filen/FilenBackend.cs b/Duplicati/Library/Backend/Filen/FilenBackend.cs index 3f953a754..5fbfc9c5e 100644 --- a/Duplicati/Library/Backend/Filen/FilenBackend.cs +++ b/Duplicati/Library/Backend/Filen/FilenBackend.cs @@ -243,8 +243,8 @@ public class FilenBackend : IStreamingBackend, IRenameEnabledBackend ); /// - public Task TestAsync(CancellationToken cancellationToken) - => this.TestReadWritePermissionsAsync(cancellationToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancellationToken) + => this.TestBackendAsync(alsoWrite, cancellationToken); /// public async Task CreateFolderAsync(CancellationToken cancellationToken) diff --git a/Duplicati/Library/Backend/GoogleServices/GoogleCloudStorage.cs b/Duplicati/Library/Backend/GoogleServices/GoogleCloudStorage.cs index 6c50bb276..17eff9109 100644 --- a/Duplicati/Library/Backend/GoogleServices/GoogleCloudStorage.cs +++ b/Duplicati/Library/Backend/GoogleServices/GoogleCloudStorage.cs @@ -298,8 +298,8 @@ namespace Duplicati.Library.Backend.GoogleCloudStorage } } - public Task TestAsync(CancellationToken cancelToken) - => this.TestReadWritePermissionsAsync(cancelToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) + => this.TestBackendAsync(alsoWrite, cancelToken); public async Task CreateFolderAsync(CancellationToken cancelToken) { diff --git a/Duplicati/Library/Backend/GoogleServices/GoogleDrive.cs b/Duplicati/Library/Backend/GoogleServices/GoogleDrive.cs index 1d1ba4323..d3bebd62b 100644 --- a/Duplicati/Library/Backend/GoogleServices/GoogleDrive.cs +++ b/Duplicati/Library/Backend/GoogleServices/GoogleDrive.cs @@ -403,8 +403,8 @@ namespace Duplicati.Library.Backend.GoogleDrive } } - public Task TestAsync(CancellationToken cancelToken) - => this.TestReadWritePermissionsAsync(cancelToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) + => this.TestBackendAsync(alsoWrite, cancelToken); public async Task CreateFolderAsync(CancellationToken cancelToken) { diff --git a/Duplicati/Library/Backend/Idrivee2/Idrivee2Backend.cs b/Duplicati/Library/Backend/Idrivee2/Idrivee2Backend.cs index 10564d1fb..427553524 100644 --- a/Duplicati/Library/Backend/Idrivee2/Idrivee2Backend.cs +++ b/Duplicati/Library/Backend/Idrivee2/Idrivee2Backend.cs @@ -215,8 +215,8 @@ namespace Duplicati.Library.Backend public string Description => Strings.Idrivee2Backend.Description; /// - public Task TestAsync(CancellationToken cancelToken) - => this.TestReadWritePermissionsAsync(cancelToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) + => this.TestBackendAsync(alsoWrite, cancelToken); /// public bool SupportsStreaming => true; diff --git a/Duplicati/Library/Backend/Jottacloud/Jottacloud.cs b/Duplicati/Library/Backend/Jottacloud/Jottacloud.cs index c406dc830..e607d3d19 100644 --- a/Duplicati/Library/Backend/Jottacloud/Jottacloud.cs +++ b/Duplicati/Library/Backend/Jottacloud/Jottacloud.cs @@ -362,8 +362,8 @@ public class Jottacloud : IStreamingBackend, IRenameEnabledBackend public bool SupportsStreaming => true; /// - public Task TestAsync(CancellationToken cancelToken) - => this.TestReadWritePermissionsAsync(cancelToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) + => this.TestBackendAsync(alsoWrite, cancelToken); /// public async Task CreateFolderAsync(CancellationToken cancelToken) diff --git a/Duplicati/Library/Backend/Mega/MegaBackend.cs b/Duplicati/Library/Backend/Mega/MegaBackend.cs index beb93d4e4..4043f74d2 100644 --- a/Duplicati/Library/Backend/Mega/MegaBackend.cs +++ b/Duplicati/Library/Backend/Mega/MegaBackend.cs @@ -251,8 +251,8 @@ namespace Duplicati.Library.Backend.Mega } /// - public Task TestAsync(CancellationToken cancelToken) - => this.TestReadWritePermissionsAsync(cancelToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) + => this.TestBackendAsync(alsoWrite, cancelToken); /// public Task CreateFolderAsync(CancellationToken cancelToken) diff --git a/Duplicati/Library/Backend/MovistarCloud/MovistarCloudBackend.cs b/Duplicati/Library/Backend/MovistarCloud/MovistarCloudBackend.cs index c776ed6cf..b9fff3281 100644 --- a/Duplicati/Library/Backend/MovistarCloud/MovistarCloudBackend.cs +++ b/Duplicati/Library/Backend/MovistarCloud/MovistarCloudBackend.cs @@ -385,7 +385,7 @@ public sealed class MovistarCloudBackend : IBackend } /// - public async Task TestAsync(CancellationToken cancellationToken) + public async Task TestAsync(bool alsoWrite, CancellationToken cancellationToken) { var client = await GetClientAsync(cancellationToken).ConfigureAwait(false); @@ -421,6 +421,8 @@ public sealed class MovistarCloudBackend : IBackend { await RunDiagnosticsAsync(cancellationToken).ConfigureAwait(false); } + + await this.TestBackendAsync(alsoWrite, cancellationToken).ConfigureAwait(false); } /// diff --git a/Duplicati/Library/Backend/OneDrive/MicrosoftGraphBackend.cs b/Duplicati/Library/Backend/OneDrive/MicrosoftGraphBackend.cs index 22fda1614..c0e660f38 100644 --- a/Duplicati/Library/Backend/OneDrive/MicrosoftGraphBackend.cs +++ b/Duplicati/Library/Backend/OneDrive/MicrosoftGraphBackend.cs @@ -650,7 +650,7 @@ namespace Duplicati.Library.Backend } } - public async Task TestAsync(CancellationToken cancelToken) + public async Task TestAsync(bool alsoWrite, CancellationToken cancelToken) { try { @@ -664,7 +664,7 @@ namespace Duplicati.Library.Backend throw new FolderMissingException(ex); } - await this.TestReadWritePermissionsAsync(cancelToken).ConfigureAwait(false); + await this.TestBackendAsync(alsoWrite, cancelToken).ConfigureAwait(false); } public void Dispose() diff --git a/Duplicati/Library/Backend/OpenStack/OpenStackStorage.cs b/Duplicati/Library/Backend/OpenStack/OpenStackStorage.cs index b8633dad4..5c49b3ead 100644 --- a/Duplicati/Library/Backend/OpenStack/OpenStackStorage.cs +++ b/Duplicati/Library/Backend/OpenStack/OpenStackStorage.cs @@ -446,8 +446,8 @@ public class OpenStackStorage : IStreamingBackend, IRenameEnabledBackend } /// - public Task TestAsync(CancellationToken cancelToken) - => this.TestReadWritePermissionsAsync(cancelToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) + => this.TestBackendAsync(alsoWrite, cancelToken); /// public async Task CreateFolderAsync(CancellationToken cancelToken) diff --git a/Duplicati/Library/Backend/Rclone/Rclone.cs b/Duplicati/Library/Backend/Rclone/Rclone.cs index 69385ad66..7581d384b 100644 --- a/Duplicati/Library/Backend/Rclone/Rclone.cs +++ b/Duplicati/Library/Backend/Rclone/Rclone.cs @@ -297,8 +297,8 @@ namespace Duplicati.Library.Backend public Task GetDNSNamesAsync(CancellationToken cancelToken) => Task.FromResult(new[] { remote_repo }); - public Task TestAsync(CancellationToken cancelToken) - => this.TestReadWritePermissionsAsync(cancelToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) + => this.TestBackendAsync(alsoWrite, cancelToken); public Task CreateFolderAsync(CancellationToken cancelToken) { diff --git a/Duplicati/Library/Backend/S3/S3Backend.cs b/Duplicati/Library/Backend/S3/S3Backend.cs index 7729b53b3..b815c0c95 100644 --- a/Duplicati/Library/Backend/S3/S3Backend.cs +++ b/Duplicati/Library/Backend/S3/S3Backend.cs @@ -438,8 +438,8 @@ namespace Duplicati.Library.Backend public string Description => Strings.S3Backend.Description_v2; /// - public Task TestAsync(CancellationToken cancelToken) - => this.TestReadWritePermissionsAsync(cancelToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) + => this.TestBackendAsync(alsoWrite, cancelToken); /// public Task CreateFolderAsync(CancellationToken cancelToken) diff --git a/Duplicati/Library/Backend/SMB/SMBBackend.cs b/Duplicati/Library/Backend/SMB/SMBBackend.cs index 4dcb6a4fb..40f62bfcb 100644 --- a/Duplicati/Library/Backend/SMB/SMBBackend.cs +++ b/Duplicati/Library/Backend/SMB/SMBBackend.cs @@ -284,8 +284,8 @@ public class SMBBackend : IStreamingBackend, IFolderEnabledBackend, IRenameEnabl /// /// The cancellation token (not used) /// Thrown when configured path does not exist - public Task TestAsync(CancellationToken cancellationToken) - => this.TestReadWritePermissionsAsync(cancellationToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancellationToken) + => this.TestBackendAsync(alsoWrite, cancellationToken); /// /// Creates the configured remote folder path if it doesn't exist @@ -350,7 +350,7 @@ public class SMBBackend : IStreamingBackend, IFolderEnabledBackend, IRenameEnabl var con = await GetConnectionAsync(cancellationToken).ConfigureAwait(false); await con.RenameAsync(oldname, newname, cancellationToken).ConfigureAwait(false); } - + /// public async Task GetEntryAsync(string path, CancellationToken cancellationToken) { diff --git a/Duplicati/Library/Backend/SSHv2/SSHv2Backend.cs b/Duplicati/Library/Backend/SSHv2/SSHv2Backend.cs index a81c42bc2..c936de444 100644 --- a/Duplicati/Library/Backend/SSHv2/SSHv2Backend.cs +++ b/Duplicati/Library/Backend/SSHv2/SSHv2Backend.cs @@ -123,8 +123,8 @@ namespace Duplicati.Library.Backend #region IBackend Members - public Task TestAsync(CancellationToken cancelToken) - => this.TestReadWritePermissionsAsync(cancelToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) + => this.TestBackendAsync(alsoWrite, cancelToken); public async Task CreateFolderAsync(CancellationToken cancelToken) { diff --git a/Duplicati/Library/Backend/SharePoint/SharePointBackend.cs b/Duplicati/Library/Backend/SharePoint/SharePointBackend.cs index 067304b3a..829b30402 100644 --- a/Duplicati/Library/Backend/SharePoint/SharePointBackend.cs +++ b/Duplicati/Library/Backend/SharePoint/SharePointBackend.cs @@ -427,11 +427,11 @@ namespace Duplicati.Library.Backend #region [Public backend methods] - public async Task TestAsync(CancellationToken cancelToken) + public async Task TestAsync(bool alsoWrite, CancellationToken cancelToken) { var ctx = await GetSpClientContextAsync(true, cancelToken).ConfigureAwait(false); await TestContextForWebAsync(ctx, true, m_timeouts.ShortTimeout, cancelToken).ConfigureAwait(false); - await this.TestReadWritePermissionsAsync(cancelToken).ConfigureAwait(false); + await this.TestBackendAsync(alsoWrite, cancelToken).ConfigureAwait(false); } /// diff --git a/Duplicati/Library/Backend/Storj/StorjBackend.cs b/Duplicati/Library/Backend/Storj/StorjBackend.cs index 1864d32d2..0236dcece 100644 --- a/Duplicati/Library/Backend/Storj/StorjBackend.cs +++ b/Duplicati/Library/Backend/Storj/StorjBackend.cs @@ -262,8 +262,8 @@ namespace Duplicati.Library.Backend.Storj throw new Exception(upload.ErrorMessage); } - public Task TestAsync(CancellationToken cancelToken) - => Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancelToken, ct => TestImplAsync(ct)); + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) + => Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancelToken, ct => TestImplAsync(alsoWrite, ct)); /// /// Test the connection by: @@ -271,22 +271,31 @@ namespace Duplicati.Library.Backend.Storj /// - uploading 256 random bytes to a test-file /// - downloading the file back and expecting 256 bytes /// + /// If true, the test will also write a file to the backend + /// The cancellation token /// true, if the test was successfull or and exception - private async Task TestImplAsync(CancellationToken cancelToken) + private async Task TestImplAsync(bool alsoWrite, CancellationToken cancelToken) { var testFileName = GetBasePath() + "duplicati_test.dat"; var bucket = await GetBucketAsync(cancelToken).ConfigureAwait(false); - var upload = await _objectService.UploadObjectAsync(bucket, testFileName, new UploadOptions(), GetRandomBytes(256), false).ConfigureAwait(false); - await upload.StartUploadAsync().ConfigureAwait(false); + if (alsoWrite) + { + var upload = await _objectService.UploadObjectAsync(bucket, testFileName, new UploadOptions(), GetRandomBytes(256), false).ConfigureAwait(false); + await upload.StartUploadAsync().ConfigureAwait(false); - var download = await _objectService.DownloadObjectAsync(bucket, testFileName, new DownloadOptions(), false).ConfigureAwait(false); - await download.StartDownloadAsync().ConfigureAwait(false); + var download = await _objectService.DownloadObjectAsync(bucket, testFileName, new DownloadOptions(), false).ConfigureAwait(false); + await download.StartDownloadAsync().ConfigureAwait(false); - await _objectService.DeleteObjectAsync(bucket, testFileName).ConfigureAwait(false); + await _objectService.DeleteObjectAsync(bucket, testFileName).ConfigureAwait(false); - if (download.Failed || download.BytesReceived != 256) - throw new Exception(download.ErrorMessage); + if (download.Failed || download.BytesReceived != 256) + throw new Exception(download.ErrorMessage); + } + else + { + await _objectService.ListObjectsAsync(bucket, new ListObjectsOptions { Prefix = GetBasePath() }).ConfigureAwait(false); + } return true; } diff --git a/Duplicati/Library/Backend/TahoeLAFS/TahoeBackend.cs b/Duplicati/Library/Backend/TahoeLAFS/TahoeBackend.cs index b9c357230..651aa9657 100644 --- a/Duplicati/Library/Backend/TahoeLAFS/TahoeBackend.cs +++ b/Duplicati/Library/Backend/TahoeLAFS/TahoeBackend.cs @@ -77,8 +77,8 @@ public class TahoeBackend : IStreamingBackend, IRenameEnabledBackend } /// - public Task TestAsync(CancellationToken cancelToken) - => this.TestReadWritePermissionsAsync(cancelToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) + => this.TestBackendAsync(alsoWrite, cancelToken); /// public async Task CreateFolderAsync(CancellationToken cancelToken) diff --git a/Duplicati/Library/Backend/TencentCOS/COSBackend.cs b/Duplicati/Library/Backend/TencentCOS/COSBackend.cs index 6ed69d341..a7c4d6188 100644 --- a/Duplicati/Library/Backend/TencentCOS/COSBackend.cs +++ b/Duplicati/Library/Backend/TencentCOS/COSBackend.cs @@ -243,7 +243,7 @@ namespace Duplicati.Library.Backend.TencentCOS } } - public async Task TestAsync(CancellationToken cancelToken) + public async Task TestAsync(bool alsoWrite, CancellationToken cancelToken) { var json = JsonConvert.SerializeObject(_cosOptions); try @@ -271,7 +271,8 @@ namespace Duplicati.Library.Backend.TencentCOS throw; } - await this.TestReadWritePermissionsAsync(cancelToken).ConfigureAwait(false); + if (alsoWrite) + await this.TestReadWritePermissionsAsync(cancelToken).ConfigureAwait(false); } public Task CreateFolderAsync(CancellationToken cancelToken) diff --git a/Duplicati/Library/Backend/WEBDAV/WEBDAV.cs b/Duplicati/Library/Backend/WEBDAV/WEBDAV.cs index bb1471960..9ed66b000 100644 --- a/Duplicati/Library/Backend/WEBDAV/WEBDAV.cs +++ b/Duplicati/Library/Backend/WEBDAV/WEBDAV.cs @@ -588,8 +588,8 @@ namespace Duplicati.Library.Backend public Task GetDNSNamesAsync(CancellationToken cancelToken) => Task.FromResult(new[] { m_dnsName }); /// - public Task TestAsync(CancellationToken cancelToken) - => this.TestReadWritePermissionsAsync(cancelToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) + => this.TestBackendAsync(alsoWrite, cancelToken); /// public async Task CreateFolderAsync(CancellationToken cancelToken) diff --git a/Duplicati/Library/Backend/pCloud/pCloudBackend.cs b/Duplicati/Library/Backend/pCloud/pCloudBackend.cs index 7c659754a..c5097605d 100644 --- a/Duplicati/Library/Backend/pCloud/pCloudBackend.cs +++ b/Duplicati/Library/Backend/pCloud/pCloudBackend.cs @@ -426,14 +426,15 @@ public class pCloudBackend : IStreamingBackend, IRenameEnabledBackend /// /// Tests backend connectivity by verifying the configured path exists /// + /// If true, also test write permissions /// The cancellation token (not used) /// Thrown when configured path does not exist - public async Task TestAsync(CancellationToken cancellationToken) + public async Task TestAsync(bool alsoWrite, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_Path) || _Path.Split(PATH_SEPARATORS, StringSplitOptions.RemoveEmptyEntries).Length == 0) return; - await this.TestReadWritePermissionsAsync(cancellationToken).ConfigureAwait(false); + await this.TestBackendAsync(alsoWrite, cancellationToken).ConfigureAwait(false); } /// diff --git a/Duplicati/Library/DynamicLoader/SourceProviderLoader.cs b/Duplicati/Library/DynamicLoader/SourceProviderLoader.cs index 36d32e620..26382353f 100644 --- a/Duplicati/Library/DynamicLoader/SourceProviderLoader.cs +++ b/Duplicati/Library/DynamicLoader/SourceProviderLoader.cs @@ -220,7 +220,7 @@ namespace Duplicati.Library.DynamicLoader { // In test mode, we do not initialize the provider if (!getForTesting) - await provider.Initialize(cancellationToken).ConfigureAwait(false); + await provider.InitializeAsync(cancellationToken).ConfigureAwait(false); return provider; } catch diff --git a/Duplicati/Library/Interface/IBackend.cs b/Duplicati/Library/Interface/IBackend.cs index 0cef06c57..919dcedc5 100644 --- a/Duplicati/Library/Interface/IBackend.cs +++ b/Duplicati/Library/Interface/IBackend.cs @@ -1,22 +1,22 @@ -// Copyright (C) 2026, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// Copyright (C) 2026, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; @@ -96,8 +96,9 @@ namespace Duplicati.Library.Interface /// If the encountered problem is a missing target "folder", /// this method should throw a . /// + /// If true, the test should also attempt to write a file to the backend. /// Token to cancel the operation. - Task TestAsync(CancellationToken cancellationToken); + Task TestAsync(bool alsoWrite, CancellationToken cancellationToken); /// /// The purpose of this method is to create the underlying "folder". diff --git a/Duplicati/Library/Interface/ISourceProvider.cs b/Duplicati/Library/Interface/ISourceProvider.cs index 90278983a..5e0f6f960 100644 --- a/Duplicati/Library/Interface/ISourceProvider.cs +++ b/Duplicati/Library/Interface/ISourceProvider.cs @@ -43,21 +43,21 @@ public interface ISourceProvider : IDisposable /// /// The cancellation token /// An awaitable task - Task Initialize(CancellationToken cancellationToken); + Task InitializeAsync(CancellationToken cancellationToken); /// /// Tests the provider connection /// /// The cancellation token /// An awaitable task - Task Test(CancellationToken cancellationToken); + Task TestAsync(CancellationToken cancellationToken); /// /// Gets the root entries /// /// The cancellation token /// An enumerable of file entries - IAsyncEnumerable Enumerate(CancellationToken cancellationToken); + IAsyncEnumerable EnumerateAsync(CancellationToken cancellationToken); /// /// Gets a specific entry @@ -66,5 +66,5 @@ public interface ISourceProvider : IDisposable /// True if the path is a folder /// The cancellation token /// The file entry - Task GetEntry(string path, bool isFolder, CancellationToken cancellationToken); + Task GetEntryAsync(string path, bool isFolder, CancellationToken cancellationToken); } diff --git a/Duplicati/Library/Main/Operation/Backup/FileEnumerationProcess.cs b/Duplicati/Library/Main/Operation/Backup/FileEnumerationProcess.cs index ab2436bc2..1a8df6fc2 100644 --- a/Duplicati/Library/Main/Operation/Backup/FileEnumerationProcess.cs +++ b/Duplicati/Library/Main/Operation/Backup/FileEnumerationProcess.cs @@ -113,7 +113,7 @@ namespace Duplicati.Library.Main.Operation.Backup { foreach (var s in list) { - var r = await sourceProvider.GetEntry(s, s.EndsWith(Path.DirectorySeparatorChar), token).ConfigureAwait(false); + var r = await sourceProvider.GetEntryAsync(s, s.EndsWith(Path.DirectorySeparatorChar), token).ConfigureAwait(false); if (r != null) { //TODO: Set r.IsRoot = true for source elements @@ -166,7 +166,7 @@ namespace Duplicati.Library.Main.Operation.Backup } else { - worklist = RecurseEntriesAsync(sourceProvider.Enumerate(token), + worklist = RecurseEntriesAsync(sourceProvider.EnumerateAsync(token), FilterEntry, token ); diff --git a/Duplicati/Library/Main/Operation/TestFilterHandler.cs b/Duplicati/Library/Main/Operation/TestFilterHandler.cs index 92712ebf3..f6f67f06d 100644 --- a/Duplicati/Library/Main/Operation/TestFilterHandler.cs +++ b/Duplicati/Library/Main/Operation/TestFilterHandler.cs @@ -86,7 +86,7 @@ namespace Duplicati.Library.Main.Operation // Go for the symlink target, as we know we follow symlinks if (!string.IsNullOrWhiteSpace(symlinkTarget)) { - var targetEntry = await provider.GetEntry(symlinkTarget, false, stopToken).ConfigureAwait(false); + var targetEntry = await provider.GetEntryAsync(symlinkTarget, false, stopToken).ConfigureAwait(false); fa = FileAttributes.Normal; try { fa = targetEntry!.Attributes; } @@ -94,7 +94,7 @@ namespace Duplicati.Library.Main.Operation // 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); + targetEntry = await provider.GetEntryAsync(symlinkTarget, true, stopToken).ConfigureAwait(false); // No such target if (targetEntry == null) diff --git a/Duplicati/Library/SourceProvider/Builtin/BackendSourceProvider.cs b/Duplicati/Library/SourceProvider/Builtin/BackendSourceProvider.cs index 1d0e8b92c..d040da149 100644 --- a/Duplicati/Library/SourceProvider/Builtin/BackendSourceProvider.cs +++ b/Duplicati/Library/SourceProvider/Builtin/BackendSourceProvider.cs @@ -70,7 +70,7 @@ public class BackendSourceProvider(IFolderEnabledBackend backend, string mounted => new BackendSourceFileEntry(this, "", true, true, new DateTime(0), new DateTime(0), 0); /// - public async Task Initialize(CancellationToken cancellationToken) + public async Task InitializeAsync(CancellationToken cancellationToken) { // Only allow a single intiiialization call if (Interlocked.Exchange(ref isInitialized, 1) != 0) @@ -83,15 +83,15 @@ public class BackendSourceProvider(IFolderEnabledBackend backend, string mounted } /// - public Task Test(CancellationToken cancellationToken) - => backend.TestAsync(cancellationToken); + public Task TestAsync(CancellationToken cancellationToken) + => backend.TestAsync(false, cancellationToken); /// - public IAsyncEnumerable Enumerate(CancellationToken cancellationToken) + public IAsyncEnumerable EnumerateAsync(CancellationToken cancellationToken) => new[] { Interlocked.Exchange(ref preparedRoot, null) ?? CreateRoot() }.ToAsyncEnumerable(); /// - public async Task GetEntry(string path, bool isFolder, CancellationToken cancellationToken) + public async Task GetEntryAsync(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); diff --git a/Duplicati/Library/SourceProvider/Builtin/Combiner.cs b/Duplicati/Library/SourceProvider/Builtin/Combiner.cs index a006ac551..9e354ce00 100644 --- a/Duplicati/Library/SourceProvider/Builtin/Combiner.cs +++ b/Duplicati/Library/SourceProvider/Builtin/Combiner.cs @@ -38,19 +38,19 @@ public class Combiner(IEnumerable providers) : ISourceProvider 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 Task InitializeAsync(CancellationToken cancellationToken) + => Task.WhenAll(providers.Select(x => x.InitializeAsync(cancellationToken))); /// - public Task Test(CancellationToken cancellationToken) - => Task.WhenAll(providers.Select(x => x.Test(cancellationToken))); + public Task TestAsync(CancellationToken cancellationToken) + => Task.WhenAll(providers.Select(x => x.TestAsync(cancellationToken))); /// - public async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable EnumerateAsync([EnumeratorCancellation] CancellationToken cancellationToken) { foreach (var provider in providers) { - await foreach (var entry in provider.Enumerate(cancellationToken).ConfigureAwait(false)) + await foreach (var entry in provider.EnumerateAsync(cancellationToken).ConfigureAwait(false)) yield return entry; } } @@ -67,18 +67,18 @@ public class Combiner(IEnumerable providers) : ISourceProvider /// True if the path is a folder /// The cancellation token /// The filesystem entry - public async Task GetEntry(string path, bool isFolder, CancellationToken cancellationToken) + public async Task GetEntryAsync(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); + return await provider.GetEntryAsync(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); + var res = await provider.GetEntryAsync(path, isFolder, cancellationToken).ConfigureAwait(false); if (res != null) return res; } diff --git a/Duplicati/Library/SourceProvider/Builtin/LocalFileSource.cs b/Duplicati/Library/SourceProvider/Builtin/LocalFileSource.cs index cf1ee6cfa..c45700363 100644 --- a/Duplicati/Library/SourceProvider/Builtin/LocalFileSource.cs +++ b/Duplicati/Library/SourceProvider/Builtin/LocalFileSource.cs @@ -33,10 +33,10 @@ public class LocalFileSource(ISnapshotService snapshotService) : ISourceProvider public string MountedPath => string.Empty; /// - public Task Initialize(CancellationToken cancellationToken) => Task.CompletedTask; + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// - public IAsyncEnumerable Enumerate(CancellationToken cancellationToken) + public IAsyncEnumerable EnumerateAsync(CancellationToken cancellationToken) => snapshotService.EnumerateFilesystemEntries().ToAsyncEnumerable(); /// @@ -45,11 +45,11 @@ public class LocalFileSource(ISnapshotService snapshotService) : ISourceProvider public ISnapshotService SnapshotService => snapshotService; /// - public Task GetEntry(string path, bool isFolder, CancellationToken cancellationToken) + public Task GetEntryAsync(string path, bool isFolder, CancellationToken cancellationToken) => Task.FromResult(snapshotService.GetFilesystemEntry(path, isFolder)); /// - public Task Test(CancellationToken cancellationToken) + public Task TestAsync(CancellationToken cancellationToken) => snapshotService.DirectoryExists(MountedPath) ? Task.CompletedTask : throw new Exception($"The path {MountedPath} does not exist"); /// diff --git a/Duplicati/Library/Utility/BackendExtensions.cs b/Duplicati/Library/Utility/BackendExtensions.cs index 333074464..e571c8562 100644 --- a/Duplicati/Library/Utility/BackendExtensions.cs +++ b/Duplicati/Library/Utility/BackendExtensions.cs @@ -46,11 +46,23 @@ public static class BackendExtensions public const string TEST_FILE_CONTENT = "This file is used by Duplicati to test access permissions and can be safely deleted."; /// - /// Tests a backend by invoking the List() method. - /// As long as the iteration can either complete or find at least one file without throwing, the test is successful + /// Test the backend in either read-only or read-write mode. + /// + /// The backend to test + /// If the test is also checking the destination for write permissions + /// Cancellation token + /// An awaitable tasks + public static Task TestBackendAsync(this IBackend backend, bool alsoWrite, CancellationToken cancellationToken) + => alsoWrite + ? TestReadWritePermissionsAsync(backend, cancellationToken) + : TestReadPermissionsAsync(backend, cancellationToken); + + /// + /// Tests a backend by invoking the List() method, and attempting to write a small file on the destination. /// /// Backend to test /// Cancellation token + /// An awaitable tasks public static async Task TestReadWritePermissionsAsync(this IBackend backend, CancellationToken cancellationToken) { // Remove the file if it exists @@ -142,4 +154,32 @@ public static class BackendExtensions throw new TestAfterConnectException(Strings.BackendExtensions.ErrorDeleteFile(TEST_FILE_NAME, e.Message), "TestCleanupError", e); } } + + /// + /// Tests a backend by invoking the List() method. + /// As long as the iteration can return one page, the test is considered successful. + /// + /// Backend to test + /// Cancellation token + /// An awaitable tasks + public static async Task TestReadPermissionsAsync(this IBackend backend, CancellationToken cancellationToken) + { + try + { + await foreach (var entry in backend + .ListAsync(cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + break; + } + } + catch (Exception e) + // Don't catch FolderMissingException or FileMissingException + // so we can pass those through to the caller + when (e is not FolderMissingException && e is not FileMissingException) + { + throw new UserInformationException(Strings.BackendExtensions.ErrorListContent(e.Message), "TestPreparationError", e); + } + } } diff --git a/Duplicati/UnitTest/ArchiveAttributeTests.cs b/Duplicati/UnitTest/ArchiveAttributeTests.cs index abf853a71..ad68c1ae5 100644 --- a/Duplicati/UnitTest/ArchiveAttributeTests.cs +++ b/Duplicati/UnitTest/ArchiveAttributeTests.cs @@ -81,8 +81,8 @@ namespace Duplicati.UnitTest public Task PutAsync(string remotename, string filename, CancellationToken cancellationToken) => backend.PutAsync(remotename, filename, cancellationToken); - public Task TestAsync(CancellationToken cancellationToken) - => backend.TestAsync(cancellationToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancellationToken) + => backend.TestAsync(alsoWrite, cancellationToken); } [Test] diff --git a/Duplicati/UnitTest/DeterministicErrorBackend.cs b/Duplicati/UnitTest/DeterministicErrorBackend.cs index b2bf97a38..d233d3430 100644 --- a/Duplicati/UnitTest/DeterministicErrorBackend.cs +++ b/Duplicati/UnitTest/DeterministicErrorBackend.cs @@ -111,9 +111,9 @@ namespace Duplicati.UnitTest await m_backend.DeleteAsync(remotename, cancelToken).ConfigureAwait(false); ThrowError(BackendAction.DeleteAfter, remotename); } - public Task TestAsync(CancellationToken cancelToken) + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) { - return m_backend.TestAsync(cancelToken); + return m_backend.TestAsync(alsoWrite, cancelToken); } public Task CreateFolderAsync(CancellationToken cancelToken) { diff --git a/Duplicati/UnitTest/DiskImage/DiskImageTests.cs b/Duplicati/UnitTest/DiskImage/DiskImageTests.cs index b083859dd..7306a42d7 100644 --- a/Duplicati/UnitTest/DiskImage/DiskImageTests.cs +++ b/Duplicati/UnitTest/DiskImage/DiskImageTests.cs @@ -902,11 +902,11 @@ public class DiskImageTests : BasicSetupHelper using var provider = new SourceProvider(sourceUrl, "", new Dictionary()); // Initialize the provider - await provider.Initialize(CancellationToken.None); + await provider.InitializeAsync(CancellationToken.None); // Enumerate entries var diskEntries = new List(); - await foreach (var entry in provider.Enumerate(CancellationToken.None)) + await foreach (var entry in provider.EnumerateAsync(CancellationToken.None)) { diskEntries.Add(entry); await TestContext.Progress.WriteLineAsync($"Found entry: {entry.Path} (IsFolder: {entry.IsFolder}, Size: {entry.Size})"); diff --git a/Duplicati/UnitTest/Issue5845.cs b/Duplicati/UnitTest/Issue5845.cs index 1f37ca1a2..c566eff98 100644 --- a/Duplicati/UnitTest/Issue5845.cs +++ b/Duplicati/UnitTest/Issue5845.cs @@ -79,8 +79,8 @@ namespace Duplicati.UnitTest public Task PutAsync(string remotename, string filename, CancellationToken cancellationToken) => backend.PutAsync(remotename, filename, cancellationToken); - public Task TestAsync(CancellationToken cancellationToken) - => backend.TestAsync(cancellationToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancellationToken) + => backend.TestAsync(alsoWrite, cancellationToken); } [Test] diff --git a/Duplicati/UnitTest/Issue5862.cs b/Duplicati/UnitTest/Issue5862.cs index 3507dae7a..01d3b696b 100644 --- a/Duplicati/UnitTest/Issue5862.cs +++ b/Duplicati/UnitTest/Issue5862.cs @@ -75,8 +75,8 @@ namespace Duplicati.UnitTest public Task PutAsync(string remotename, string filename, CancellationToken cancellationToken) => throw new DeterministicErrorBackend.DeterministicErrorBackendException("Prevent"); - public Task TestAsync(CancellationToken cancellationToken) - => backend.TestAsync(cancellationToken); + public Task TestAsync(bool alsoWrite, CancellationToken cancellationToken) + => backend.TestAsync(alsoWrite, cancellationToken); } [Test] diff --git a/Duplicati/UnitTest/RandomErrorBackend.cs b/Duplicati/UnitTest/RandomErrorBackend.cs index 060143f06..7a04a4849 100644 --- a/Duplicati/UnitTest/RandomErrorBackend.cs +++ b/Duplicati/UnitTest/RandomErrorBackend.cs @@ -94,9 +94,9 @@ namespace Duplicati.UnitTest await m_backend.DeleteAsync(remotename, cancelToken).ConfigureAwait(false); ThrowErrorRandom(); } - public Task TestAsync(CancellationToken cancelToken) + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) { - return m_backend.TestAsync(cancelToken); + return m_backend.TestAsync(alsoWrite, cancelToken); } public Task CreateFolderAsync(CancellationToken cancelToken) { diff --git a/Duplicati/UnitTest/SizeOmittingBackend.cs b/Duplicati/UnitTest/SizeOmittingBackend.cs index f6b437ae0..6a6c02d55 100644 --- a/Duplicati/UnitTest/SizeOmittingBackend.cs +++ b/Duplicati/UnitTest/SizeOmittingBackend.cs @@ -78,9 +78,9 @@ namespace Duplicati.UnitTest { await m_backend.DeleteAsync(remotename, cancelToken).ConfigureAwait(false); } - public Task TestAsync(CancellationToken cancelToken) + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) { - return m_backend.TestAsync(cancelToken); + return m_backend.TestAsync(alsoWrite, cancelToken); } public Task CreateFolderAsync(CancellationToken cancelToken) { diff --git a/Duplicati/UnitTest/SoftDeleteTests.cs b/Duplicati/UnitTest/SoftDeleteTests.cs index 21689a930..211f86169 100644 --- a/Duplicati/UnitTest/SoftDeleteTests.cs +++ b/Duplicati/UnitTest/SoftDeleteTests.cs @@ -506,9 +506,9 @@ namespace Duplicati.UnitTest return m_backend.DeleteAsync(remotename, cancelToken); } - public Task TestAsync(CancellationToken cancelToken) + public Task TestAsync(bool alsoWrite, CancellationToken cancelToken) { - return m_backend.TestAsync(cancelToken); + return m_backend.TestAsync(alsoWrite, cancelToken); } public Task CreateFolderAsync(CancellationToken cancelToken) diff --git a/Duplicati/WebserverCore/Dto/V2/DestinationTestRequestDto.cs b/Duplicati/WebserverCore/Dto/V2/DestinationTestRequestDto.cs index 0bcf1618f..84d2f81b1 100644 --- a/Duplicati/WebserverCore/Dto/V2/DestinationTestRequestDto.cs +++ b/Duplicati/WebserverCore/Dto/V2/DestinationTestRequestDto.cs @@ -67,6 +67,11 @@ public sealed record DestinationTestRequestDto /// public required bool AutoCreate { get; init; } = false; + /// + /// Whether to test read-only access + /// + public bool ReadOnlyTest { get; init; } = false; + /// /// The type of remote destination /// diff --git a/Duplicati/WebserverCore/Endpoints/V1/RemoteOperation.cs b/Duplicati/WebserverCore/Endpoints/V1/RemoteOperation.cs index 23b71c7ef..29faa99d4 100644 --- a/Duplicati/WebserverCore/Endpoints/V1/RemoteOperation.cs +++ b/Duplicati/WebserverCore/Endpoints/V1/RemoteOperation.cs @@ -39,8 +39,8 @@ namespace Duplicati.WebserverCore.Endpoints.V1 => ExecuteDbPath(input.path)) .RequireAuthorization(); - group.MapPost("/remoteoperation/test", ([FromServices] Connection connection, [FromServices] IApplicationSettings applicationSettings, [FromQuery] bool? autocreate, [FromQuery] Dto.V2.RemoteDestinationType? type, [FromBody] RemoteOperationInput input, CancellationToken cancelToken) - => ExecuteTestAsync(connection, applicationSettings, input.path, input.backupId, input.connectionStringId, input.sourcePrefix, autocreate ?? false, type ?? Dto.V2.RemoteDestinationType.Backend, cancelToken)) + group.MapPost("/remoteoperation/test", ([FromServices] Connection connection, [FromServices] IApplicationSettings applicationSettings, [FromQuery] bool? autocreate, [FromQuery] bool? readOnlyTest, [FromQuery] Dto.V2.RemoteDestinationType? type, [FromBody] RemoteOperationInput input, CancellationToken cancelToken) + => ExecuteTestAsync(connection, applicationSettings, input.path, input.backupId, input.connectionStringId, input.sourcePrefix, autocreate ?? false, readOnlyTest ?? false, type ?? Dto.V2.RemoteDestinationType.Backend, cancelToken)) .RequireAuthorization(); group.MapPost("/remoteoperation/create", ([FromServices] Connection connection, [FromServices] IApplicationSettings applicationSettings, [FromBody] RemoteOperationInput input, CancellationToken cancelToken) @@ -54,7 +54,7 @@ namespace Duplicati.WebserverCore.Endpoints.V1 return new Dto.GetDbPathDto(!string.IsNullOrWhiteSpace(path), path); } - private static async Task ExecuteTestAsync(Connection connection, IApplicationSettings applicationSettings, string maskedurl, string? backupId, long? connectionStringId, string? sourcePrefix, bool autoCreate, Dto.V2.RemoteDestinationType type, CancellationToken cancelToken) + private static async Task ExecuteTestAsync(Connection connection, IApplicationSettings applicationSettings, string maskedurl, string? backupId, long? connectionStringId, string? sourcePrefix, bool autoCreate, bool readOnlyTest, Dto.V2.RemoteDestinationType type, CancellationToken cancelToken) { try { @@ -62,7 +62,7 @@ namespace Duplicati.WebserverCore.Endpoints.V1 { using var wrapper = await SharedRemoteOperation.GetSourceProviderForTestingAsync(connection, applicationSettings, maskedurl, backupId, connectionStringId ?? -1, sourcePrefix, cancelToken); using (var s = wrapper.SourceProvider) - await s.Test(cancelToken).ConfigureAwait(false); + await s.TestAsync(cancelToken).ConfigureAwait(false); } else if (type == Dto.V2.RemoteDestinationType.RestoreDestinationProvider) { @@ -75,14 +75,14 @@ namespace Duplicati.WebserverCore.Endpoints.V1 using var wrapper = await SharedRemoteOperation.GetBackendAsync(connection, applicationSettings, maskedurl, backupId, connectionStringId ?? -1, cancelToken); using (var b = wrapper.Backend) { - try { await b.TestAsync(cancelToken).ConfigureAwait(false); } + try { await b.TestAsync(!readOnlyTest, cancelToken).ConfigureAwait(false); } catch (Exception ex) when (SharedRemoteOperation.GetInnerException(ex) is FolderMissingException) { - if (!autoCreate) + if (!autoCreate || readOnlyTest) throw; await b.CreateFolderAsync(cancelToken).ConfigureAwait(false); - await b.TestAsync(cancelToken).ConfigureAwait(false); + await b.TestAsync(!readOnlyTest, cancelToken).ConfigureAwait(false); } } } diff --git a/Duplicati/WebserverCore/Endpoints/V2/DestinationVerify.cs b/Duplicati/WebserverCore/Endpoints/V2/DestinationVerify.cs index f621bfc1b..78494f1a5 100644 --- a/Duplicati/WebserverCore/Endpoints/V2/DestinationVerify.cs +++ b/Duplicati/WebserverCore/Endpoints/V2/DestinationVerify.cs @@ -49,14 +49,11 @@ public class DestinationVerify : IEndpointV2 { using var wrapper = await SharedRemoteOperation.GetSourceProviderForTestingAsync(connection, applicationSettings, input.DestinationUrl, input.BackupId, input.ConnectionStringId ?? -1, input.SourcePrefix, cancelToken); - // We do not call TestAsync here, because we may have read-only access to the source, and test will verify write access - // Instead we just check if we can enumerate the files, which is the main thing we need to verify for a source provider - - // Technically we also count folders as files here, but really we just want to know if there is data to backup - var anyFiles = await wrapper.SourceProvider.Enumerate(cancelToken).AnyAsync(cancelToken); + // Call the specific Test method on the ISourceProvider (read-only test) + await wrapper.SourceProvider.TestAsync(cancelToken); return DestinationTestResponseDto.Create( - anyFiles: anyFiles, + anyFiles: true, anyBackups: false, anyEncryptedFiles: false ); @@ -65,7 +62,7 @@ public class DestinationVerify : IEndpointV2 { using var wrapper = await SharedRemoteOperation.GetRestoreDestinationProviderForTestingAsync(connection, applicationSettings, input.DestinationUrl, input.BackupId, input.ConnectionStringId ?? -1, input.SourcePrefix, cancelToken); - // Here we do call TestAsync, because we need to verify write access + // Call the specific Test method on the IRestoreDestinationProvider (should be a write test) await wrapper.RestoreDestinationProvider.Test(cancelToken); return DestinationTestResponseDto.Create( @@ -80,14 +77,14 @@ public class DestinationVerify : IEndpointV2 using (var b = wrapper.Backend) { - try { await b.TestAsync(cancelToken).ConfigureAwait(false); } + try { await b.TestAsync(!input.ReadOnlyTest, cancelToken).ConfigureAwait(false); } catch (Exception ex) when (SharedRemoteOperation.GetInnerException(ex) is FolderMissingException) { - if (!input.AutoCreate) + if (!input.AutoCreate || input.ReadOnlyTest) throw; await b.CreateFolderAsync(cancelToken).ConfigureAwait(false); - await b.TestAsync(cancelToken).ConfigureAwait(false); + await b.TestAsync(!input.ReadOnlyTest, cancelToken).ConfigureAwait(false); } var anyFiles = false; diff --git a/proprietary/DiskImage/SourceProvider.cs b/proprietary/DiskImage/SourceProvider.cs index 08ff573e2..e6509a34a 100644 --- a/proprietary/DiskImage/SourceProvider.cs +++ b/proprietary/DiskImage/SourceProvider.cs @@ -102,7 +102,7 @@ public sealed class SourceProvider : ISourceProviderModule, IDisposable internal bool TreatFilesystemAsUnknown => _treatFilesystemAsUnknown; /// - public async Task Initialize(CancellationToken cancellationToken) + public async Task InitializeAsync(CancellationToken cancellationToken) { if (string.IsNullOrEmpty(_devicePath)) throw new UserInformationException("Disk device path is not specified.", "DiskDeviceNotSpecified"); @@ -132,7 +132,7 @@ public sealed class SourceProvider : ISourceProviderModule, IDisposable } /// - public Task Test(CancellationToken cancellationToken) + public Task TestAsync(CancellationToken cancellationToken) { if (_disk == null) throw new InvalidOperationException("Provider not initialized."); @@ -140,7 +140,7 @@ public sealed class SourceProvider : ISourceProviderModule, IDisposable } /// - public async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable EnumerateAsync([EnumeratorCancellation] CancellationToken cancellationToken) { if (_disk == null) throw new InvalidOperationException("Provider not initialized."); @@ -150,7 +150,7 @@ public sealed class SourceProvider : ISourceProviderModule, IDisposable } /// - public async Task GetEntry(string path, bool isFolder, CancellationToken cancellationToken) + public async Task GetEntryAsync(string path, bool isFolder, CancellationToken cancellationToken) { if (_disk == null) throw new InvalidOperationException("Provider not initialized."); diff --git a/proprietary/DiskImage/WebModule.cs b/proprietary/DiskImage/WebModule.cs index 423d30734..beca17639 100644 --- a/proprietary/DiskImage/WebModule.cs +++ b/proprietary/DiskImage/WebModule.cs @@ -64,7 +64,7 @@ public class WebModule : IWebModule var subpath = parts.Last(); using var client = new SourceProvider("diskimage://" + physicalDrivePath, "", new Dictionary(options)); - await client.Initialize(cancellationToken); + await client.InitializeAsync(cancellationToken); if (string.IsNullOrWhiteSpace(subpath)) return new Dictionary() @@ -72,7 +72,7 @@ public class WebModule : IWebModule {$"{physicalDrivePath}{Path.DirectorySeparatorChar}root{Path.DirectorySeparatorChar}", "{}"} }; - var targetEntry = await client.GetEntry(subpath, isFolder: true, cancellationToken).ConfigureAwait(false) + var targetEntry = await client.GetEntryAsync(subpath, isFolder: true, cancellationToken).ConfigureAwait(false) ?? throw new DirectoryNotFoundException($"Path not found: {path}"); var result = new Dictionary(); diff --git a/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.cs b/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.cs index a8efb7674..9af79a987 100644 --- a/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.cs +++ b/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.cs @@ -145,8 +145,8 @@ public partial class RestoreProvider : IRestoreDestinationProviderModule if (!_hasSetOverwriteOption) throw new UserInformationException(Strings.RestoreTargetMissingOverwriteOption("overwrite", OptionsHelper.GOOGLE_IGNORE_EXISTING_OPTION), "OverwriteOptionNotSet"); - await SourceProvider.Initialize(cancel); - var entry = await SourceProvider.GetEntry(_restorePath, isFolder: true, cancel); + await SourceProvider.InitializeAsync(cancel); + var entry = await SourceProvider.GetEntryAsync(_restorePath, isFolder: true, cancel); if (entry == null) throw new UserInformationException(Strings.RestoreTargetNotFound(_restorePath), "RestoreTargetNotFound"); var metadata = await entry.GetMinorMetadata(cancel); @@ -197,7 +197,7 @@ public partial class RestoreProvider : IRestoreDestinationProviderModule if (_temporaryFiles.ContainsKey(path)) return true; - var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false); + var entry = await SourceProvider.GetEntryAsync(path, isFolder: false, cancel).ConfigureAwait(false); return entry != null; } @@ -218,7 +218,7 @@ public partial class RestoreProvider : IRestoreDestinationProviderModule if (_temporaryFiles.ContainsKey(path)) return SystemIO.IO_OS.FileOpenRead(_temporaryFiles[path]); - var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false); + var entry = await SourceProvider.GetEntryAsync(path, isFolder: false, cancel).ConfigureAwait(false); if (entry != null) return await entry.OpenRead(cancel).ConfigureAwait(false); @@ -233,7 +233,7 @@ public partial class RestoreProvider : IRestoreDestinationProviderModule if (_temporaryFiles.ContainsKey(path)) return SystemIO.IO_OS.FileOpenReadWrite(_temporaryFiles[path]); - var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false); + var entry = await SourceProvider.GetEntryAsync(path, isFolder: false, cancel).ConfigureAwait(false); if (entry == null) { _temporaryFiles.GetOrAdd(path, _ => new TempFile()); @@ -260,7 +260,7 @@ public partial class RestoreProvider : IRestoreDestinationProviderModule if (_temporaryFiles.ContainsKey(path)) return SystemIO.IO_OS.FileLength(_temporaryFiles[path]); - var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false); + var entry = await SourceProvider.GetEntryAsync(path, isFolder: false, cancel).ConfigureAwait(false); if (entry == null) throw new FileNotFoundException($"File not found: {path}"); diff --git a/proprietary/GoogleWorkspace/SourceProvider/SourceProvider.cs b/proprietary/GoogleWorkspace/SourceProvider/SourceProvider.cs index 17890eccf..5210d60cb 100644 --- a/proprietary/GoogleWorkspace/SourceProvider/SourceProvider.cs +++ b/proprietary/GoogleWorkspace/SourceProvider/SourceProvider.cs @@ -114,12 +114,12 @@ public sealed class SourceProvider : ISourceProviderModule, IDisposable { } - public IAsyncEnumerable Enumerate(CancellationToken cancellationToken) + public IAsyncEnumerable EnumerateAsync(CancellationToken cancellationToken) { return new RootSourceEntry(this).Enumerate(cancellationToken); } - public async Task GetEntry(string path, bool isFolder, CancellationToken cancellationToken) + public async Task GetEntryAsync(string path, bool isFolder, CancellationToken cancellationToken) { if (_entryCache.TryGetValue(path, out var cachedEntry)) return cachedEntry; @@ -179,7 +179,7 @@ public sealed class SourceProvider : ISourceProviderModule, IDisposable return resultEntry; } - public Task Initialize(CancellationToken cancellationToken) + public Task InitializeAsync(CancellationToken cancellationToken) { if (!_hasSetMetadataStorageOption) throw new UserInformationException(Strings.MetadataStorageNotEnabled("store-metadata-content-in-database"), "DatabaseMetadataStorageNotEnabled"); @@ -187,7 +187,7 @@ public sealed class SourceProvider : ISourceProviderModule, IDisposable return Task.CompletedTask; } - public Task Test(CancellationToken cancellationToken) + public Task TestAsync(CancellationToken cancellationToken) { _apiHelper.TestConnection(); return Task.CompletedTask; diff --git a/proprietary/GoogleWorkspace/WebModule/WebModule.cs b/proprietary/GoogleWorkspace/WebModule/WebModule.cs index ea60e57de..2cba7fd31 100644 --- a/proprietary/GoogleWorkspace/WebModule/WebModule.cs +++ b/proprietary/GoogleWorkspace/WebModule/WebModule.cs @@ -95,9 +95,9 @@ public class WebModule : IWebModule forwardoptions[key!] = uri.QueryParameters[key]; using var client = new SourceProvider(url, "", forwardoptions, true); - await client.Initialize(cancellationToken); + await client.InitializeAsync(cancellationToken); - var targetEntry = await client.GetEntry((path ?? "").TrimStart('/'), isFolder: true, cancellationToken).ConfigureAwait(false); + var targetEntry = await client.GetEntryAsync((path ?? "").TrimStart('/'), isFolder: true, cancellationToken).ConfigureAwait(false); if (targetEntry == null) throw new DirectoryNotFoundException($"Path not found: {path}"); diff --git a/proprietary/Office365/RestoreProvider/RestoreProvider.Email.cs b/proprietary/Office365/RestoreProvider/RestoreProvider.Email.cs index f62156c6a..78441b40e 100644 --- a/proprietary/Office365/RestoreProvider/RestoreProvider.Email.cs +++ b/proprietary/Office365/RestoreProvider/RestoreProvider.Email.cs @@ -76,7 +76,7 @@ partial class RestoreProvider Uri.EscapeDataString(Office365UserType.Mailbox.ToString().ToLowerInvariant()) }); - var mailbox = await Provider.SourceProvider.GetEntry(path, true, cancel); + var mailbox = await Provider.SourceProvider.GetEntryAsync(path, true, cancel); if (mailbox == null) throw new InvalidOperationException($"Mailbox not found for user {userId}"); diff --git a/proprietary/Office365/RestoreProvider/RestoreProvider.cs b/proprietary/Office365/RestoreProvider/RestoreProvider.cs index 8482503c3..28e5030f9 100644 --- a/proprietary/Office365/RestoreProvider/RestoreProvider.cs +++ b/proprietary/Office365/RestoreProvider/RestoreProvider.cs @@ -192,8 +192,8 @@ public partial class RestoreProvider : IRestoreDestinationProviderModule throw new UserInformationException(Strings.RestoreTargetMissingOverwriteOption("overwrite", OptionsHelper.OFFICE_IGNORE_EXISTING_OPTION), "OverwriteOptionNotSet"); await _apiHelper.AcquireAccessTokenAsync(true, cancel); - await SourceProvider.Initialize(cancel); - var entry = await SourceProvider.GetEntry(_restorePath, isFolder: true, cancel); + await SourceProvider.InitializeAsync(cancel); + var entry = await SourceProvider.GetEntryAsync(_restorePath, isFolder: true, cancel); if (entry == null) throw new UserInformationException($"Restore target path not found: {_restorePath}", "RestoreTargetNotFound"); var metadata = await entry.GetMinorMetadata(cancel); @@ -261,7 +261,7 @@ public partial class RestoreProvider : IRestoreDestinationProviderModule if (_temporaryFiles.ContainsKey(path)) return true; - var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false); + var entry = await SourceProvider.GetEntryAsync(path, isFolder: false, cancel).ConfigureAwait(false); return entry != null; } @@ -282,7 +282,7 @@ public partial class RestoreProvider : IRestoreDestinationProviderModule if (_temporaryFiles.ContainsKey(path)) return SystemIO.IO_OS.FileOpenRead(_temporaryFiles[path]); - var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false); + var entry = await SourceProvider.GetEntryAsync(path, isFolder: false, cancel).ConfigureAwait(false); if (entry != null) return await entry.OpenRead(cancel).ConfigureAwait(false); @@ -297,7 +297,7 @@ public partial class RestoreProvider : IRestoreDestinationProviderModule if (_temporaryFiles.ContainsKey(path)) return SystemIO.IO_OS.FileOpenReadWrite(_temporaryFiles[path]); - var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false); + var entry = await SourceProvider.GetEntryAsync(path, isFolder: false, cancel).ConfigureAwait(false); if (entry == null) { _temporaryFiles.GetOrAdd(path, _ => new TempFile()); @@ -324,7 +324,7 @@ public partial class RestoreProvider : IRestoreDestinationProviderModule if (_temporaryFiles.ContainsKey(path)) return SystemIO.IO_OS.FileLength(_temporaryFiles[path]); - var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false); + var entry = await SourceProvider.GetEntryAsync(path, isFolder: false, cancel).ConfigureAwait(false); if (entry == null) throw new FileNotFoundException($"File not found: {path}"); diff --git a/proprietary/Office365/SourceProvider/SourceProvider.cs b/proprietary/Office365/SourceProvider/SourceProvider.cs index 319e8acf3..abdd948f3 100644 --- a/proprietary/Office365/SourceProvider/SourceProvider.cs +++ b/proprietary/Office365/SourceProvider/SourceProvider.cs @@ -160,7 +160,7 @@ public sealed partial class SourceProvider : ISourceProviderModule, IDisposable /// - public Task Initialize(CancellationToken cancellationToken) + public Task InitializeAsync(CancellationToken cancellationToken) { if (!_hasSetMetadataStorageOption) throw new UserInformationException(Strings.MetadataStorageNotEnabled("store-metadata-content-in-database"), "DatabaseMetadataStorageNotEnabled"); @@ -169,17 +169,17 @@ public sealed partial class SourceProvider : ISourceProviderModule, IDisposable } /// - public Task Test(CancellationToken cancellationToken) + public Task TestAsync(CancellationToken cancellationToken) => _apiHelper.AcquireAccessTokenAsync(false, cancellationToken); /// - public async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable EnumerateAsync([EnumeratorCancellation] CancellationToken cancellationToken) { yield return new RootSourceEntry(this, _mountPoint); } /// - public async Task GetEntry(string path, bool isFolder, CancellationToken cancellationToken) + public async Task GetEntryAsync(string path, bool isFolder, CancellationToken cancellationToken) { if (_entryCache.TryGetValue(path, out var cachedEntry)) return cachedEntry; diff --git a/proprietary/Office365/WebModule/WebModule.cs b/proprietary/Office365/WebModule/WebModule.cs index 846ebaaae..0733c4bd5 100644 --- a/proprietary/Office365/WebModule/WebModule.cs +++ b/proprietary/Office365/WebModule/WebModule.cs @@ -90,9 +90,9 @@ public class WebModule : IWebModule forwardoptions[key!] = uri.QueryParameters[key]; using var client = new SourceProvider(url, "", forwardoptions); - await client.Initialize(cancellationToken); + await client.InitializeAsync(cancellationToken); - var targetEntry = await client.GetEntry((path ?? "").TrimStart('/'), isFolder: true, cancellationToken).ConfigureAwait(false); + var targetEntry = await client.GetEntryAsync((path ?? "").TrimStart('/'), isFolder: true, cancellationToken).ConfigureAwait(false); if (targetEntry == null) throw new DirectoryNotFoundException($"Path not found: {path}");