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).
This commit is contained in:
Kenneth Skovhede
2026-01-28 14:37:51 +01:00
parent 2cb0ddceb3
commit 8a08b35546
12 changed files with 513 additions and 41 deletions
+101 -4
View File
@@ -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<string> GetCurrentFolderAsync(bool create, CancellationToken cancelToken)
{
var id = await GetFolderIdAsync(_path, create, cancelToken).ConfigureAwait(false);
return _currentFolder = id;
}
private async Task<string> 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<IFileEntry> 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<IFileEntry?> 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<string> GetFileIdAsync(string name, CancellationToken cancelToken)
+61 -4
View File
@@ -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<IFileEntry> ListAsync([EnumeratorCancellation] CancellationToken cancelToken)
public IAsyncEnumerable<IFileEntry> ListAsync(CancellationToken cancelToken)
=> ListAsync(null, cancelToken);
public async IAsyncEnumerable<IFileEntry> 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<IFileEntry?> 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))
@@ -87,6 +87,32 @@ namespace Duplicati.Library.Backend
}).ConfigureAwait(false);
}
public async Task<MetaData?> 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<MetaData>(ct).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize MetaData");
}).ConfigureAwait(false);
}
private async Task<HttpRequestMessage> CreateChunkRequestAsync<T>(string url, T arg, CancellationToken cancelToken)
{
var req = await CreateRequestAsync(url, HttpMethod.Post, cancelToken).ConfigureAwait(false);
@@ -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";
@@ -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<IFileEntry> 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<IFileEntry?> 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;
@@ -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.
/// </remarks>
public abstract class MicrosoftGraphBackend : IBackend, IStreamingBackend, IQuotaEnabledBackend, IRenameEnabledBackend
public abstract class MicrosoftGraphBackend : IBackend, IStreamingBackend, IQuotaEnabledBackend, IRenameEnabledBackend, IFolderEnabledBackend
{
private static readonly string LOGTAG = Log.LogTagFromType<MicrosoftGraphBackend>();
@@ -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<IFileEntry> 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<DriveItem>(url, cancelToken).ConfigureAwait(false))
{
if (!item.IsDeleted)
yield return ParseEntry(item);
}
}
public async Task<IFileEntry?> 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<DriveItem>(url, ct)
).ConfigureAwait(false);
return ParseEntry(item);
}
catch (DriveItemNotFoundException)
{
return null;
}
}
/// <inheritdoc />
public async IAsyncEnumerable<IFileEntry> 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);
}
}
}
@@ -104,5 +104,14 @@ namespace Duplicati.Library.Backend
/// <param name="lockUntilUtc">The UTC timestamp until which the object should remain locked.</param>
/// <param name="cancelToken">The cancellation token.</param>
Task SetObjectLockUntilAsync(string bucketName, string keyName, DateTime lockUntilUtc, CancellationToken cancelToken);
/// <summary>
/// Gets the metadata for a single object
/// </summary>
/// <param name="bucketName">The bucket name</param>
/// <param name="keyName">The object key</param>
/// <param name="cancellationToken">The cancellation token</param>
/// <returns>The file entry, or null if not found</returns>
Task<IFileEntry?> GetFileEntryAsync(string bucketName, string keyName, CancellationToken cancellationToken);
}
}
@@ -495,6 +495,26 @@ namespace Duplicati.Library.Backend
throw new FileMissingException(string.Format("File {0} not found", keyName), s3Ex);
}
public async Task<IFileEntry?> 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()
+6 -1
View File
@@ -448,6 +448,11 @@ namespace Duplicati.Library.Backend
/// <inheritdoc/>
public Task<IFileEntry?> GetEntryAsync(string path, CancellationToken cancellationToken)
=> Task.FromResult<IFileEntry?>(null);
{
if (string.IsNullOrWhiteSpace(path))
return Task.FromResult<IFileEntry?>(new FileEntry(string.Empty) { IsFolder = true });
return m_s3Client.GetFileEntryAsync(m_bucket, GetFullKey(path), cancellationToken);
}
}
}
@@ -110,6 +110,24 @@ namespace Duplicati.Library.Backend
}
}
public async Task<IFileEntry?> 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
+9 -2
View File
@@ -345,6 +345,13 @@ public class SMBBackend : IStreamingBackend, IFolderEnabledBackend
}
/// <inheritdoc/>
public Task<IFileEntry?> GetEntryAsync(string path, CancellationToken cancellationToken)
=> Task.FromResult<IFileEntry?>(null);
public async Task<IFileEntry?> 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);
}
}
@@ -302,7 +302,74 @@ public class SMBShareConnection : IDisposable, IAsyncDisposable
{
_semaphore.Release();
}
}
public async Task<IFileEntry?> 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();
}
}
/// <summary>