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(); + } } ///