From 8a08b35546bd731bd0e7d4aa9e7ab066fdf14990 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Wed, 28 Jan 2026 14:37:51 +0100 Subject: [PATCH] Extended support for remote sources This PR adds support for using Box, Dropbox, GoogleDrive, SharepointV2, MicrosoftGroups, and OneDrive as remote sources. For GoogleDrive, this requires full permissions and not just the regular OAuth access token. Also extended S3 and SMB to actually look up a file if requested, which makes it possible to rely on the backup exclusion files, even when using remote sources. (SSH/SFTP does not currently support this). --- Duplicati/Library/Backend/Box/BoxBackend.cs | 105 ++++++++++++- Duplicati/Library/Backend/Dropbox/Dropbox.cs | 65 +++++++- .../Library/Backend/Dropbox/DropboxHelper.cs | 26 ++++ Duplicati/Library/Backend/Dropbox/WebApi.cs | 4 + .../Backend/GoogleServices/GoogleDrive.cs | 139 +++++++++++++++--- .../Backend/OneDrive/MicrosoftGraphBackend.cs | 83 ++++++++++- Duplicati/Library/Backend/S3/IS3Client.cs | 9 ++ Duplicati/Library/Backend/S3/S3AwsClient.cs | 20 +++ Duplicati/Library/Backend/S3/S3Backend.cs | 7 +- Duplicati/Library/Backend/S3/S3MinioClient.cs | 18 +++ Duplicati/Library/Backend/SMB/SMBBackend.cs | 11 +- .../Library/Backend/SMB/SMBShareConnection.cs | 67 +++++++++ 12 files changed, 513 insertions(+), 41 deletions(-) diff --git a/Duplicati/Library/Backend/Box/BoxBackend.cs b/Duplicati/Library/Backend/Box/BoxBackend.cs index 38896bab7..0731d9ef6 100644 --- a/Duplicati/Library/Backend/Box/BoxBackend.cs +++ b/Duplicati/Library/Backend/Box/BoxBackend.cs @@ -31,7 +31,7 @@ using Uri = Duplicati.Library.Utility.Uri; namespace Duplicati.Library.Backend.Box { - public class BoxBackend : IStreamingBackend + public class BoxBackend : IStreamingBackend, IFolderEnabledBackend { private static readonly string TOKEN_URL = AuthIdOptionsHelper.GetOAuthLoginUrl("box.com", null); private const string AUTHID_OPTION = "authid"; @@ -112,10 +112,16 @@ namespace Duplicati.Library.Backend.Box } private async Task GetCurrentFolderAsync(bool create, CancellationToken cancelToken) + { + var id = await GetFolderIdAsync(_path, create, cancelToken).ConfigureAwait(false); + return _currentFolder = id; + } + + private async Task GetFolderIdAsync(string path, bool create, CancellationToken cancelToken) { var parentid = "0"; - foreach (var p in _path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries)) + foreach (var p in path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries)) { var el = (MiniFolder?)await PagedFileListResponse(parentid, true, cancelToken).FirstOrDefaultAsync(x => x.Name == p, cancellationToken: cancelToken).ConfigureAwait(false); if (el == null) @@ -136,10 +142,101 @@ namespace Duplicati.Library.Backend.Box parentid = el.ID; if (string.IsNullOrWhiteSpace(parentid)) - throw new InvalidDataException($"Invalid folder ID for {p} in {_path}"); + throw new InvalidDataException($"Invalid folder ID for {p} in {path}"); } - return _currentFolder = parentid; + return parentid; + } + + private IFileEntry ParseEntry(FileEntity n) + { + var fe = new FileEntry(n.Name); + if (n.Type == "folder") + { + fe.IsFolder = true; + if (!fe.Name.EndsWith("/")) + fe.Name += "/"; + } + else + { + fe.IsFolder = false; + fe.Size = n.Size; + } + + if (n.ModifiedAt != default) + fe.LastModification = fe.LastAccess = n.ModifiedAt.ToUniversalTime(); + + return fe; + } + + public async IAsyncEnumerable ListAsync(string? path, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var fullPath = _path; + if (!string.IsNullOrEmpty(path)) + { + var p = path.TrimStart('/'); + if (string.IsNullOrEmpty(fullPath)) + fullPath = "/" + p; + else if (fullPath == "/") + fullPath = "/" + p; + else + fullPath = fullPath + "/" + p; + } + + string folderId; + try + { + folderId = await GetFolderIdAsync(fullPath, false, cancellationToken).ConfigureAwait(false); + } + catch (FolderMissingException) + { + yield break; + } + + await foreach (var n in PagedFileListResponse(folderId, false, cancellationToken).ConfigureAwait(false)) + { + yield return ParseEntry(n); + } + } + + public async Task GetEntryAsync(string path, CancellationToken cancellationToken) + { + var fullPath = _path; + if (!string.IsNullOrEmpty(path)) + { + var p = path.TrimStart('/'); + if (string.IsNullOrEmpty(fullPath)) + fullPath = "/" + p; + else if (fullPath == "/") + fullPath = "/" + p; + else + fullPath = fullPath + "/" + p; + } + + var parts = fullPath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + return new FileEntry(string.Empty) { IsFolder = true }; + + var parentPath = string.Join("/", parts.Take(parts.Length - 1)); + var name = parts.Last(); + + string parentId; + try + { + parentId = await GetFolderIdAsync(parentPath, false, cancellationToken).ConfigureAwait(false); + } + catch (FolderMissingException) + { + return null; + } + + var item = await PagedFileListResponse(parentId, false, cancellationToken) + .FirstOrDefaultAsync(x => x.Name == name, cancellationToken) + .ConfigureAwait(false); + + if (item == null) return null; + + return ParseEntry(item); } private async Task GetFileIdAsync(string name, CancellationToken cancelToken) diff --git a/Duplicati/Library/Backend/Dropbox/Dropbox.cs b/Duplicati/Library/Backend/Dropbox/Dropbox.cs index 5036710a4..d45b3a80a 100644 --- a/Duplicati/Library/Backend/Dropbox/Dropbox.cs +++ b/Duplicati/Library/Backend/Dropbox/Dropbox.cs @@ -28,7 +28,7 @@ namespace Duplicati.Library.Backend { // ReSharper disable once UnusedMember.Global // This class is instantiated dynamically in the BackendLoader. - public class Dropbox : IBackend, IStreamingBackend + public class Dropbox : IBackend, IStreamingBackend, IFolderEnabledBackend { private static readonly string TOKEN_URL = AuthIdOptionsHelper.GetOAuthLoginUrl("dropbox", null); private readonly string m_path; @@ -78,7 +78,11 @@ namespace Duplicati.Library.Backend private IFileEntry ParseEntry(MetaData md) { - var ife = new FileEntry(md.name); + var name = md.name; + if (!md.IsFile && !string.IsNullOrEmpty(name) && !name.EndsWith("/")) + name += "/"; + + var ife = new FileEntry(name); if (md.IsFile) { ife.IsFolder = false; @@ -118,9 +122,27 @@ namespace Duplicati.Library.Backend } } - public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) + public IAsyncEnumerable ListAsync(CancellationToken cancelToken) + => ListAsync(null, cancelToken); + + public async IAsyncEnumerable ListAsync(string? path, [EnumeratorCancellation] CancellationToken cancelToken) { - var lfr = await HandleListExceptions(() => dbx.ListFiles(m_path, cancelToken)).ConfigureAwait(false); + var fullPath = m_path; + if (!string.IsNullOrEmpty(path)) + { + var p = path.TrimStart('/'); + if (string.IsNullOrEmpty(fullPath)) + fullPath = "/" + p; + else if (fullPath == "/") + fullPath = "/" + p; + else + fullPath = fullPath + "/" + p; + } + + if (fullPath == "/") + fullPath = ""; + + var lfr = await HandleListExceptions(() => dbx.ListFiles(fullPath, cancelToken)).ConfigureAwait(false); foreach (var md in lfr.entries ?? []) yield return ParseEntry(md); @@ -133,6 +155,41 @@ namespace Duplicati.Library.Backend } } + public async Task GetEntryAsync(string path, CancellationToken cancellationToken) + { + var fullPath = m_path; + if (!string.IsNullOrEmpty(path)) + { + var p = path.TrimStart('/'); + if (string.IsNullOrEmpty(fullPath)) + fullPath = "/" + p; + else if (fullPath == "/") + fullPath = "/" + p; + else + fullPath = fullPath + "/" + p; + } + + if (fullPath == "/") + fullPath = ""; + + if (string.IsNullOrEmpty(fullPath)) + return new FileEntry(string.Empty) { IsFolder = true }; + + try + { + var md = await dbx.GetMetadataAsync(fullPath, cancellationToken).ConfigureAwait(false); + if (md == null) + return null; + return ParseEntry(md); + } + catch (DropboxException de) + { + if (de.errorJSON?["error"]?[".tag"]?.ToString() == "path" && de.errorJSON?["error"]?["path"]?[".tag"]?.ToString() == "not_found") + return null; + throw; + } + } + public async Task PutAsync(string remotename, string filename, CancellationToken cancelToken) { using (var fs = File.OpenRead(filename)) diff --git a/Duplicati/Library/Backend/Dropbox/DropboxHelper.cs b/Duplicati/Library/Backend/Dropbox/DropboxHelper.cs index 6f68bf837..f020faeca 100644 --- a/Duplicati/Library/Backend/Dropbox/DropboxHelper.cs +++ b/Duplicati/Library/Backend/Dropbox/DropboxHelper.cs @@ -87,6 +87,32 @@ namespace Duplicati.Library.Backend }).ConfigureAwait(false); } + public async Task GetMetadataAsync(string path, CancellationToken cancellationToken) + { + return await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancellationToken, + async ct => + { + using var req = await CreateRequestAsync(WebApi.Dropbox.GetMetadataUrl(), HttpMethod.Post, ct).ConfigureAwait(false); + req.Content = JsonContent.Create(new PathArg { path = path }); + using var resp = await GetResponseAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + + if (resp.StatusCode == HttpStatusCode.Conflict) + return null; + + if (!resp.IsSuccessStatusCode) + { + var content = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (content.Contains("path_lookup/not_found")) + return null; + + throw new HttpRequestException($"Dropbox API error: {resp.StatusCode} {content}"); + } + + return await resp.Content.ReadFromJsonAsync(ct).ConfigureAwait(false) + ?? throw new InvalidOperationException("Failed to deserialize MetaData"); + }).ConfigureAwait(false); + } + private async Task CreateChunkRequestAsync(string url, T arg, CancellationToken cancelToken) { var req = await CreateRequestAsync(url, HttpMethod.Post, cancelToken).ConfigureAwait(false); diff --git a/Duplicati/Library/Backend/Dropbox/WebApi.cs b/Duplicati/Library/Backend/Dropbox/WebApi.cs index acd91bcff..fd856fc52 100644 --- a/Duplicati/Library/Backend/Dropbox/WebApi.cs +++ b/Duplicati/Library/Backend/Dropbox/WebApi.cs @@ -32,6 +32,9 @@ namespace Duplicati.Library.Backend.WebApi public static string ListFilesContinueUrl() => Utility.Uri.UriBuilder(Url.API, Path.ListFolderContinue); + public static string GetMetadataUrl() + => Utility.Uri.UriBuilder(Url.API, Path.GetMetadata); + public static string DeleteUrl() => Utility.Uri.UriBuilder(Url.API, Path.DeleteFolder); @@ -62,6 +65,7 @@ namespace Duplicati.Library.Backend.WebApi public const string DeleteFolder = "files/delete"; public const string ListFolder = "files/list_folder"; public const string ListFolderContinue = "files/list_folder/continue"; + public const string GetMetadata = "files/get_metadata"; public const string UploadSessionStart = "files/upload_session/start"; public const string UploadSessionAppend = "files/upload_session/append_v2"; diff --git a/Duplicati/Library/Backend/GoogleServices/GoogleDrive.cs b/Duplicati/Library/Backend/GoogleServices/GoogleDrive.cs index 4bc6f114a..b826167ab 100644 --- a/Duplicati/Library/Backend/GoogleServices/GoogleDrive.cs +++ b/Duplicati/Library/Backend/GoogleServices/GoogleDrive.cs @@ -32,7 +32,7 @@ namespace Duplicati.Library.Backend.GoogleDrive { // ReSharper disable once UnusedMember.Global // This class is instantiated dynamically in the BackendLoader. - public class GoogleDrive : IBackend, IStreamingBackend, IQuotaEnabledBackend, IRenameEnabledBackend + public class GoogleDrive : IBackend, IStreamingBackend, IQuotaEnabledBackend, IRenameEnabledBackend, IFolderEnabledBackend { private const string TEAMDRIVE_ID = "googledrive-teamdrive-id"; private const string FOLDER_MIMETYPE = "application/vnd.google-apps.folder"; @@ -138,6 +138,109 @@ namespace Duplicati.Library.Backend.GoogleDrive return m_filecache[remotename] = entries; } + private IFileEntry ParseEntry(GoogleDriveFolderItem n) + { + FileEntry fe; + if (n.fileSize == null) + fe = new FileEntry(n.title); + else if (n.modifiedDate == null) + fe = new FileEntry(n.title, n.fileSize.Value); + else + fe = new FileEntry(n.title, n.fileSize.Value, n.modifiedDate.Value, n.modifiedDate.Value); + + fe.IsFolder = FOLDER_MIMETYPE.Equals(n.mimeType, StringComparison.OrdinalIgnoreCase); + if (fe.IsFolder && !fe.Name.EndsWith("/")) + fe.Name += "/"; + + return fe; + } + + public async IAsyncEnumerable ListAsync(string? path, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var fullPath = m_path; + if (!string.IsNullOrEmpty(path)) + { + var p = path.TrimStart('/'); + if (string.IsNullOrEmpty(fullPath)) + fullPath = "/" + p; + else if (fullPath == "/") + fullPath = "/" + p; + else + fullPath = fullPath + "/" + p; + } + + string folderId; + try + { + folderId = await GetFolderIdAsync(fullPath, false, cancellationToken).ConfigureAwait(false); + } + catch (FolderMissingException) + { + yield break; + } + + await foreach (var n in ListFolder(folderId, null, null, cancellationToken).ConfigureAwait(false)) + { + yield return ParseEntry(n); + } + } + + public async Task GetEntryAsync(string path, CancellationToken cancellationToken) + { + var fullPath = m_path; + if (!string.IsNullOrEmpty(path)) + { + var p = path.TrimStart('/'); + if (string.IsNullOrEmpty(fullPath)) + fullPath = "/" + p; + else if (fullPath == "/") + fullPath = "/" + p; + else + fullPath = fullPath + "/" + p; + } + + var curparent = string.IsNullOrWhiteSpace(m_teamDriveID) + ? (await GetAboutInfoAsync(cancellationToken).ConfigureAwait(false)).rootFolderId + : m_teamDriveID; + + if (string.IsNullOrWhiteSpace(curparent)) + throw new UserInformationException("Unable to get root folder", "GoogleDriveNoRootFolder"); + + var parts = fullPath.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + return new FileEntry(string.Empty) { IsFolder = true }; + + GoogleDriveFolderItem? currentItem = null; + + for (var i = 0; i < parts.Length; i++) + { + var p = parts[i]; + var isLast = i == parts.Length - 1; + + // For intermediate parts, we need folders. + // For the last part, it can be file or folder. + var onlyFolders = isLast ? (bool?)null : true; + + var res = await ListFolder(curparent, onlyFolders, p, cancellationToken).ToArrayAsync(cancellationToken).ConfigureAwait(false); + + if (res.Length == 0) + return null; + + if (res.Length > 1) + throw new UserInformationException(Strings.GoogleDrive.MultipleEntries(p, fullPath), "GoogleDriveMultipleEntries"); + + currentItem = res[0]; + if (string.IsNullOrWhiteSpace(currentItem.id)) + return null; + + curparent = currentItem.id; + } + + if (currentItem == null) return null; + + return ParseEntry(currentItem); + } + private static string EscapeTitleEntries(string title) { return title.Replace("'", "\\'"); @@ -223,34 +326,22 @@ namespace Duplicati.Library.Backend.GoogleDrive var currentFolderId = await GetCurrentFolderIdAsync(cancelToken).ConfigureAwait(false); await foreach (var n in ListFolder(currentFolderId, null, null, cancelToken).ConfigureAwait(false)) { - FileEntry? fe = null; + var fe = ParseEntry(n); - if (n.fileSize == null) - fe = new FileEntry(n.title); - else if (n.modifiedDate == null) - fe = new FileEntry(n.title, n.fileSize.Value); - else - fe = new FileEntry(n.title, n.fileSize.Value, n.modifiedDate.Value, n.modifiedDate.Value); - - if (fe != null) + if (!fe.IsFolder) { - fe.IsFolder = FOLDER_MIMETYPE.Equals(n.mimeType, StringComparison.OrdinalIgnoreCase); - - if (!fe.IsFolder) + if (!m_filecache.TryGetValue(fe.Name, out var lst)) { - if (!m_filecache.TryGetValue(fe.Name, out var lst)) - { - m_filecache[fe.Name] = [n]; - } - else - { - Array.Resize(ref lst, lst.Length + 1); - lst[lst.Length - 1] = n; - } + m_filecache[fe.Name] = [n]; + } + else + { + Array.Resize(ref lst, lst.Length + 1); + lst[lst.Length - 1] = n; } - - yield return fe; } + + yield return fe; } success = true; diff --git a/Duplicati/Library/Backend/OneDrive/MicrosoftGraphBackend.cs b/Duplicati/Library/Backend/OneDrive/MicrosoftGraphBackend.cs index 2a112a9e1..d963a3c94 100644 --- a/Duplicati/Library/Backend/OneDrive/MicrosoftGraphBackend.cs +++ b/Duplicati/Library/Backend/OneDrive/MicrosoftGraphBackend.cs @@ -44,7 +44,7 @@ namespace Duplicati.Library.Backend /// Note that instead of using Task.Result to wait for the results of asynchronous operations, /// this class uses the Utility.Await() extension method, since it doesn't wrap exceptions in AggregateExceptions. /// - public abstract class MicrosoftGraphBackend : IBackend, IStreamingBackend, IQuotaEnabledBackend, IRenameEnabledBackend + public abstract class MicrosoftGraphBackend : IBackend, IStreamingBackend, IQuotaEnabledBackend, IRenameEnabledBackend, IFolderEnabledBackend { private static readonly string LOGTAG = Log.LogTagFromType(); @@ -294,6 +294,81 @@ namespace Duplicati.Library.Backend } } + private IFileEntry ParseEntry(DriveItem item) + { + var fe = new FileEntry( + item.Name, + item.Size ?? 0, + item.FileSystemInfo?.LastAccessedDateTime?.UtcDateTime ?? new DateTime(), + item.FileSystemInfo?.LastModifiedDateTime?.UtcDateTime ?? item.LastModifiedDateTime?.UtcDateTime ?? new DateTime()); + + fe.IsFolder = item.IsFolder; + + if (fe.IsFolder && !fe.Name.EndsWith("/")) + fe.Name += "/"; + + return fe; + } + + public async IAsyncEnumerable ListAsync(string? path, [EnumeratorCancellation] CancellationToken cancelToken) + { + var drivePrefix = await GetDrivePrefix(cancelToken).ConfigureAwait(false); + var targetPath = this.RootPath; + if (!string.IsNullOrEmpty(path)) + { + var p = path.TrimStart('/'); + if (targetPath.EndsWith("/")) + targetPath += p; + else + targetPath += "/" + p; + } + + string url; + if (targetPath == "/" || string.IsNullOrEmpty(targetPath)) + url = $"{drivePrefix}/root/children"; + else + url = $"{drivePrefix}/root:{targetPath}:/children"; + + await foreach (var item in this.Enumerate(url, cancelToken).ConfigureAwait(false)) + { + if (!item.IsDeleted) + yield return ParseEntry(item); + } + } + + public async Task GetEntryAsync(string path, CancellationToken cancellationToken) + { + var drivePrefix = await GetDrivePrefix(cancellationToken).ConfigureAwait(false); + var targetPath = this.RootPath; + if (!string.IsNullOrEmpty(path)) + { + var p = path.TrimStart('/'); + if (targetPath.EndsWith("/")) + targetPath += p; + else + targetPath += "/" + p; + } + + string url; + if (targetPath == "/" || string.IsNullOrEmpty(targetPath)) + url = $"{drivePrefix}/root"; + else + url = $"{drivePrefix}/root:{targetPath}"; + + try + { + var item = await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancellationToken, + ct => GetAsync(url, ct) + ).ConfigureAwait(false); + + return ParseEntry(item); + } + catch (DriveItemNotFoundException) + { + return null; + } + } + /// public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { @@ -303,11 +378,7 @@ namespace Duplicati.Library.Backend // Exclude non-files and deleted items (not sure if they show up in this listing, but make sure anyway) if (item.IsFile && !item.IsDeleted) { - yield return new FileEntry( - item.Name, - item.Size ?? 0, // Files should always have a size, but folders don't need it - item.FileSystemInfo?.LastAccessedDateTime?.UtcDateTime ?? new DateTime(), - item.FileSystemInfo?.LastModifiedDateTime?.UtcDateTime ?? item.LastModifiedDateTime?.UtcDateTime ?? new DateTime()); + yield return ParseEntry(item); } } } diff --git a/Duplicati/Library/Backend/S3/IS3Client.cs b/Duplicati/Library/Backend/S3/IS3Client.cs index 6df53da1c..06d3f7cad 100644 --- a/Duplicati/Library/Backend/S3/IS3Client.cs +++ b/Duplicati/Library/Backend/S3/IS3Client.cs @@ -104,5 +104,14 @@ namespace Duplicati.Library.Backend /// The UTC timestamp until which the object should remain locked. /// The cancellation token. Task SetObjectLockUntilAsync(string bucketName, string keyName, DateTime lockUntilUtc, CancellationToken cancelToken); + + /// + /// Gets the metadata for a single object + /// + /// The bucket name + /// The object key + /// The cancellation token + /// The file entry, or null if not found + Task GetFileEntryAsync(string bucketName, string keyName, CancellationToken cancellationToken); } } \ No newline at end of file diff --git a/Duplicati/Library/Backend/S3/S3AwsClient.cs b/Duplicati/Library/Backend/S3/S3AwsClient.cs index fba8e7211..f8d29e695 100644 --- a/Duplicati/Library/Backend/S3/S3AwsClient.cs +++ b/Duplicati/Library/Backend/S3/S3AwsClient.cs @@ -495,6 +495,26 @@ namespace Duplicati.Library.Backend throw new FileMissingException(string.Format("File {0} not found", keyName), s3Ex); } + public async Task GetFileEntryAsync(string bucketName, string keyName, CancellationToken cancellationToken) + { + try + { + var request = new GetObjectMetadataRequest { BucketName = bucketName, Key = keyName }; + var response = await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancellationToken, ct => m_client.GetObjectMetadataAsync(request, ct)).ConfigureAwait(false); + + return new FileEntry(keyName, response.ContentLength, response.LastModified ?? default, response.LastModified ?? default) + { + IsFolder = keyName.EndsWith("/") + }; + } + catch (AmazonS3Exception ex) + { + if (ex.StatusCode == System.Net.HttpStatusCode.NotFound || "NoSuchKey".Equals(ex.ErrorCode, StringComparison.OrdinalIgnoreCase)) + return null; + throw; + } + } + #region IDisposable Members public void Dispose() diff --git a/Duplicati/Library/Backend/S3/S3Backend.cs b/Duplicati/Library/Backend/S3/S3Backend.cs index 437235330..2116ca208 100644 --- a/Duplicati/Library/Backend/S3/S3Backend.cs +++ b/Duplicati/Library/Backend/S3/S3Backend.cs @@ -448,6 +448,11 @@ namespace Duplicati.Library.Backend /// public Task GetEntryAsync(string path, CancellationToken cancellationToken) - => Task.FromResult(null); + { + if (string.IsNullOrWhiteSpace(path)) + return Task.FromResult(new FileEntry(string.Empty) { IsFolder = true }); + + return m_s3Client.GetFileEntryAsync(m_bucket, GetFullKey(path), cancellationToken); + } } } diff --git a/Duplicati/Library/Backend/S3/S3MinioClient.cs b/Duplicati/Library/Backend/S3/S3MinioClient.cs index 6ad141e45..92b4feb78 100644 --- a/Duplicati/Library/Backend/S3/S3MinioClient.cs +++ b/Duplicati/Library/Backend/S3/S3MinioClient.cs @@ -110,6 +110,24 @@ namespace Duplicati.Library.Backend } } + public async Task GetFileEntryAsync(string bucketName, string keyName, CancellationToken cancellationToken) + { + try + { + var args = new StatObjectArgs().WithBucket(bucketName).WithObject(keyName); + var response = await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancellationToken, ct => m_client.StatObjectAsync(args, ct)).ConfigureAwait(false); + + return new FileEntry(keyName, response.Size, response.LastModified, response.LastModified) + { + IsFolder = keyName.EndsWith("/") + }; + } + catch (ObjectNotFoundException) + { + return null; + } + } + public async Task AddBucketAsync(string bucketName, CancellationToken cancelToken) { try diff --git a/Duplicati/Library/Backend/SMB/SMBBackend.cs b/Duplicati/Library/Backend/SMB/SMBBackend.cs index 17f747422..9d05b8179 100644 --- a/Duplicati/Library/Backend/SMB/SMBBackend.cs +++ b/Duplicati/Library/Backend/SMB/SMBBackend.cs @@ -345,6 +345,13 @@ public class SMBBackend : IStreamingBackend, IFolderEnabledBackend } /// - public Task GetEntryAsync(string path, CancellationToken cancellationToken) - => Task.FromResult(null); + public async Task GetEntryAsync(string path, CancellationToken cancellationToken) + { + var sourcePath = _connectionParameters.Path; + if (!string.IsNullOrWhiteSpace(sourcePath)) + sourcePath = Util.AppendDirSeparator(sourcePath, "/"); + + var con = await GetConnectionAsync(cancellationToken).ConfigureAwait(false); + return await con.GetEntryAsync(sourcePath + BackendSourceFileEntry.NormalizePathTo(path, '/'), cancellationToken).ConfigureAwait(false); + } } \ No newline at end of file diff --git a/Duplicati/Library/Backend/SMB/SMBShareConnection.cs b/Duplicati/Library/Backend/SMB/SMBShareConnection.cs index a77bafd12..13fd73675 100644 --- a/Duplicati/Library/Backend/SMB/SMBShareConnection.cs +++ b/Duplicati/Library/Backend/SMB/SMBShareConnection.cs @@ -302,7 +302,74 @@ public class SMBShareConnection : IDisposable, IAsyncDisposable { _semaphore.Release(); } + } + public async Task GetEntryAsync(string path, CancellationToken cancellationToken) + { + await _semaphore.WaitAsync(cancellationToken); + + object? fileHandle = null; + FileStatus fileStatus; + try + { + return await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancellationToken, _ => + { + var status = _smbFileStore.CreateFile( + out fileHandle, + out fileStatus, + NormalizeSlashes(path), + AccessMask.GENERIC_READ, + 0, + ShareAccess.Read | ShareAccess.Write | ShareAccess.Delete, + CreateDisposition.FILE_OPEN, + 0, + null); + + if (status != NTStatus.STATUS_SUCCESS) + { + return null; + } + + try + { + FileInformation basicInfo; + status = _smbFileStore.GetFileInformation(out basicInfo, fileHandle, FileInformationClass.FileBasicInformation); + if (status != NTStatus.STATUS_SUCCESS) return null; + + FileInformation standardInfo; + status = _smbFileStore.GetFileInformation(out standardInfo, fileHandle, FileInformationClass.FileStandardInformation); + if (status != NTStatus.STATUS_SUCCESS) return null; + + var basic = (FileBasicInformation)basicInfo; + var standard = (FileStandardInformation)standardInfo; + + var name = path.TrimEnd('/').Split('/').Last(); + if (string.IsNullOrEmpty(name)) name = ""; + + var isFolder = (basic.FileAttributes & FileAttributes.Directory) == FileAttributes.Directory; + if (isFolder && !name.EndsWith("/")) name += "/"; + + return (IFileEntry)new FileEntry( + name, + standard.EndOfFile, + (DateTime?)basic.LastAccessTime ?? default, + (DateTime?)basic.LastWriteTime ?? default) + { + IsFolder = isFolder, + Created = (DateTime?)basic.CreationTime ?? default + }; + } + finally + { + if (fileHandle != null) + _smbFileStore.CloseFile(fileHandle); + } + }).ConfigureAwait(false); + } + finally + { + _semaphore.Release(); + } } ///