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).
571 lines
25 KiB
C#
571 lines
25 KiB
C#
// Copyright (C) 2025, The Duplicati Team
|
|
// https://duplicati.com, hello@duplicati.com
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a
|
|
// copy of this software and associated documentation files (the "Software"),
|
|
// to deal in the Software without restriction, including without limitation
|
|
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
// and/or sell copies of the Software, and to permit persons to whom the
|
|
// Software is furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
// DEALINGS IN THE SOFTWARE.
|
|
|
|
using Duplicati.Library.Backend.SMB.Model;
|
|
using Duplicati.Library.Common.IO;
|
|
using Duplicati.Library.Interface;
|
|
using Duplicati.Library.Localization.Short;
|
|
using Duplicati.Library.Utility;
|
|
using Duplicati.Library.Utility.Options;
|
|
using SMBLibrary;
|
|
using SMBLibrary.Client;
|
|
using FileAttributes = SMBLibrary.FileAttributes;
|
|
|
|
namespace Duplicati.Library.Backend.SMB;
|
|
|
|
/// <summary>
|
|
/// Class the wraps the SMB connection and file store objects, handling the connection,
|
|
/// logon and logoff operations as well as safely disposing resources.
|
|
/// </summary>
|
|
public class SMBShareConnection : IDisposable, IAsyncDisposable
|
|
{
|
|
/// <summary>
|
|
/// SMBConnection client
|
|
/// </summary>
|
|
private readonly SMB2Client _smb2Client;
|
|
|
|
/// <summary>
|
|
/// Shared fileStore object.
|
|
/// </summary>
|
|
private readonly ISMBFileStore _smbFileStore;
|
|
|
|
/// <summary>
|
|
/// Connection parameters
|
|
/// </summary>
|
|
private readonly SMBConnectionParameters _connectionParameters;
|
|
|
|
/// <summary>
|
|
/// The timeouts to use for operations.
|
|
/// </summary>
|
|
private readonly TimeoutOptionsHelper.Timeouts _timeouts;
|
|
|
|
/// <summary>
|
|
/// The semaphore to ensure that only one operation is performed at a time.
|
|
/// </summary>
|
|
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
|
|
|
/// <summary>
|
|
/// Flag to indicate if the object has been disposed.
|
|
/// </summary>
|
|
private bool _disposed;
|
|
|
|
/// <summary>
|
|
/// This constructor will connect to the server and share as specified in the connection parameters.
|
|
///
|
|
/// It throws specific exceptions for connection and authentication failures.
|
|
/// </summary>
|
|
/// <param name="connectionParameters">Connection Parameters</param>
|
|
/// <param name="timeouts">Timeouts to use for operations</param>
|
|
private SMBShareConnection(SMB2Client client, ISMBFileStore fileStore, SMBConnectionParameters connectionParameters, TimeoutOptionsHelper.Timeouts timeouts)
|
|
{
|
|
_connectionParameters = connectionParameters;
|
|
_timeouts = timeouts;
|
|
_smbFileStore = fileStore;
|
|
_smb2Client = client;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new SMBShareConnection object asynchronously.
|
|
/// </summary>
|
|
/// <param name="connectionParameters">The connection parameters</param>
|
|
/// <param name="timeouts">The timeouts to use for operations</param>
|
|
/// <param name="cancellationToken">The cancellation token</param>
|
|
/// <returns>The SMBShareConnection object</returns>
|
|
/// <exception cref="UserInformationException">Exception to be displayed to user</exception>
|
|
public static async Task<SMBShareConnection> CreateAsync(SMBConnectionParameters connectionParameters, TimeoutOptionsHelper.Timeouts timeouts, CancellationToken cancellationToken)
|
|
{
|
|
var client = new SMB2Client();
|
|
var connected = await Utility.Utility.WithTimeout(timeouts.ShortTimeout, cancellationToken, _ =>
|
|
client.Connect(connectionParameters.ServerName, connectionParameters.TransportType)
|
|
).ConfigureAwait(false);
|
|
|
|
if (!connected)
|
|
throw new UserInformationException($"{LC.L("Failed to connect to server")} {connectionParameters.ServerName}", "ConnectionError");
|
|
|
|
var status = await Utility.Utility.WithTimeout(timeouts.ShortTimeout, cancellationToken, _ =>
|
|
client.Login(connectionParameters.AuthDomain ?? "", connectionParameters.AuthUser ?? "", connectionParameters.AuthPassword ?? "")
|
|
).ConfigureAwait(false);
|
|
|
|
if (status != NTStatus.STATUS_SUCCESS)
|
|
throw new UserInformationException($"{LC.L("Failed to authenticate to server")} {connectionParameters.ServerName} with status {status}", "ConnectionError");
|
|
|
|
(var res, status) = await Utility.Utility.WithTimeout(timeouts.ShortTimeout, cancellationToken, _ =>
|
|
{
|
|
var res = client.TreeConnect(connectionParameters.ShareName, out status);
|
|
return (res, status);
|
|
}
|
|
).ConfigureAwait(false);
|
|
|
|
if (status != NTStatus.STATUS_SUCCESS || res == null)
|
|
throw new UserInformationException($"{LC.L("Failed to connect to share")} {connectionParameters.ShareName} with status {status}", "ConnectionError");
|
|
|
|
return new SMBShareConnection(client, res, connectionParameters, timeouts);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes the file specified in the path specified in the connection parameters.
|
|
/// </summary>
|
|
/// <param name="fileName">Filename to be deleted</param>
|
|
/// <param name="cancellationToken">The cancellation token</param>
|
|
/// <exception cref="UserInformationException">Exception to be displayed to user</exception>
|
|
public async Task DeleteAsync(string fileName, CancellationToken cancellationToken)
|
|
{
|
|
await _semaphore.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancellationToken, _ =>
|
|
{
|
|
var status = _smbFileStore.CreateFile(out var fileHandle, out var fileStatus, NormalizeSlashes(Path.Combine(_connectionParameters.Path, fileName)),
|
|
AccessMask.GENERIC_WRITE | AccessMask.DELETE | AccessMask.SYNCHRONIZE,
|
|
FileAttributes.Normal,
|
|
ShareAccess.None,
|
|
CreateDisposition.FILE_OPEN,
|
|
CreateOptions.FILE_NON_DIRECTORY_FILE | CreateOptions.FILE_SYNCHRONOUS_IO_ALERT,
|
|
null);
|
|
|
|
if (status == NTStatus.STATUS_OBJECT_NAME_NOT_FOUND)
|
|
throw new FileMissingException();
|
|
|
|
if (status == NTStatus.STATUS_SUCCESS)
|
|
{
|
|
var fileDispositionInformation = new FileDispositionInformation
|
|
{
|
|
DeletePending = true
|
|
};
|
|
status = _smbFileStore.SetFileInformation(fileHandle, fileDispositionInformation);
|
|
if (status != NTStatus.STATUS_SUCCESS)
|
|
throw new UserInformationException($"{LC.L("Failed to delete file on DeleteAsync")} with status {status}", "DeleteFileError");
|
|
status = _smbFileStore.CloseFile(fileHandle);
|
|
if (status != NTStatus.STATUS_SUCCESS)
|
|
throw new UserInformationException($"{LC.L("Failed to close file on DeleteAsync")} with status {status}", "CloseFileError");
|
|
}
|
|
}).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
_semaphore.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create the folder structure specified in the path.
|
|
/// </summary>
|
|
/// <param name="path">Path to create</param>
|
|
/// <param name="cancellationToken">Cancellation Token</param>
|
|
/// <exception cref="UserInformationException">Exception to be displayed to user</exception>
|
|
public async Task CreateFolderAsync(string path, CancellationToken cancellationToken)
|
|
{
|
|
await _semaphore.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
// Normalize path separators to forward slashes and trim any trailing separators
|
|
var linuxNormalizedPath = path.Replace('/', '\\').TrimEnd('\\');
|
|
var currentPath = "";
|
|
|
|
foreach (var part in linuxNormalizedPath.Split('\\', StringSplitOptions.RemoveEmptyEntries))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(part) || part == ".")
|
|
continue;
|
|
|
|
currentPath = currentPath.Length == 0 ? part : $"{currentPath}\\{part}";
|
|
object? fileHandle = null;
|
|
try
|
|
{
|
|
await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancellationToken, _ =>
|
|
{
|
|
var status = _smbFileStore.CreateFile(
|
|
out fileHandle,
|
|
out var fileStatus,
|
|
currentPath,
|
|
AccessMask.GENERIC_WRITE | AccessMask.SYNCHRONIZE,
|
|
FileAttributes.Normal,
|
|
ShareAccess.None,
|
|
CreateDisposition.FILE_CREATE,
|
|
CreateOptions.FILE_DIRECTORY_FILE | CreateOptions.FILE_SYNCHRONOUS_IO_ALERT,
|
|
null
|
|
);
|
|
if (status != NTStatus.STATUS_SUCCESS &&
|
|
status != NTStatus.STATUS_OBJECT_NAME_COLLISION) // Ignore if directory already exists
|
|
throw new UserInformationException($"{LC.L("Failed to create directory")} {currentPath} with status{status}", "CreateDirectoryError");
|
|
}).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
if (fileHandle != null)
|
|
{
|
|
var status = _smbFileStore.CloseFile(fileHandle);
|
|
if (status != NTStatus.STATUS_SUCCESS)
|
|
throw new UserInformationException($"{LC.L("Failed to close file handle on CreateFolderAsync")} with status {status.ToString()}", "HandleCloseError");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_semaphore.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lists the folder contents of the share and path specified in the connection parameters.
|
|
/// </summary>
|
|
/// <param name="path">Path to list</param>
|
|
/// <param name="cancellationToken">Cancellation Token</param>
|
|
/// <exception cref="UserInformationException">Exception to be displayed to user</exception>
|
|
public async Task<List<IFileEntry>> ListAsync(string path, CancellationToken cancellationToken)
|
|
{
|
|
await _semaphore.WaitAsync(cancellationToken);
|
|
|
|
object? directoryHandle = null;
|
|
FileStatus fileStatus;
|
|
try
|
|
{
|
|
try
|
|
{
|
|
var fileList = await Utility.Utility.WithTimeout(_timeouts.ListTimeout, cancellationToken, _ =>
|
|
{
|
|
var status = _smbFileStore.CreateFile(
|
|
out directoryHandle,
|
|
out fileStatus,
|
|
NormalizeSlashes(path),
|
|
AccessMask.GENERIC_READ,
|
|
FileAttributes.Directory,
|
|
ShareAccess.Read | ShareAccess.Write,
|
|
CreateDisposition.FILE_OPEN,
|
|
CreateOptions.FILE_DIRECTORY_FILE,
|
|
null);
|
|
|
|
if (status != NTStatus.STATUS_SUCCESS && fileStatus != FileStatus.FILE_OPENED)
|
|
if (status == NTStatus.STATUS_OBJECT_PATH_NOT_FOUND || status == NTStatus.STATUS_OBJECT_NAME_NOT_FOUND)
|
|
throw new FolderMissingException();
|
|
else
|
|
throw new UserInformationException($"{LC.L("Failed to open directory")} {NormalizeSlashes(path)} with status {status}", "DirectoryOpenError");
|
|
|
|
List<QueryDirectoryFileInformation> fileList;
|
|
status = _smbFileStore.QueryDirectory(
|
|
out fileList,
|
|
directoryHandle,
|
|
"*",
|
|
FileInformationClass.FileDirectoryInformation);
|
|
|
|
if (status != NTStatus.STATUS_NO_MORE_FILES)
|
|
throw new UserInformationException($"{LC.L("Failed to query directory contents")} with status {status}", "DirectoryQueryError");
|
|
|
|
return fileList;
|
|
}).ConfigureAwait(false);
|
|
|
|
return
|
|
[
|
|
..fileList
|
|
.OfType<FileDirectoryInformation>()
|
|
.Select(info => new FileEntry(
|
|
info.FileName,
|
|
info.EndOfFile,
|
|
info.LastAccessTime,
|
|
info.LastWriteTime)
|
|
{
|
|
IsFolder = info.FileAttributes == FileAttributes.Directory,
|
|
Created = info.CreationTime
|
|
})
|
|
.ToList()
|
|
];
|
|
}
|
|
finally
|
|
{
|
|
if (directoryHandle != null)
|
|
{
|
|
var status = _smbFileStore.CloseFile(directoryHandle);
|
|
if (status != NTStatus.STATUS_SUCCESS)
|
|
throw new UserInformationException($"{LC.L("Failed to close directory handle on ListAsync")} with status {status.ToString()}", "HandleCloseError");
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_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>
|
|
/// Read a file from source and write it to the destination stream.
|
|
/// </summary>
|
|
/// <param name="filename">Filename to be read</param>
|
|
/// <param name="destinationStream">Destination stream to write contents read from source</param>
|
|
/// <param name="cancellationToken">Cancellation Token</param>
|
|
public async Task GetAsync(string filename, Stream destinationStream, CancellationToken cancellationToken)
|
|
{
|
|
await _semaphore.WaitAsync(cancellationToken);
|
|
|
|
try
|
|
{
|
|
(var status, var fileStatus, var fileHandle) = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancellationToken, _ =>
|
|
{
|
|
var status = _smbFileStore.CreateFile(out var fileHandle, out var fileStatus,
|
|
NormalizeSlashes(Path.Combine(_connectionParameters.Path, filename)),
|
|
AccessMask.GENERIC_READ | AccessMask.SYNCHRONIZE, FileAttributes.Normal, ShareAccess.Read,
|
|
CreateDisposition.FILE_OPEN,
|
|
CreateOptions.FILE_NON_DIRECTORY_FILE | CreateOptions.FILE_SYNCHRONOUS_IO_ALERT, null);
|
|
return (status, fileStatus, fileHandle);
|
|
}
|
|
).ConfigureAwait(false);
|
|
|
|
if (status == NTStatus.STATUS_SUCCESS || fileStatus != FileStatus.FILE_DOES_NOT_EXIST)
|
|
{
|
|
byte[] data;
|
|
long bytesRead = 0;
|
|
using var timeoutStream = destinationStream.ObserveWriteTimeout(_timeouts.ReadWriteTimeout, false);
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
// Use the provided read buffer size if set, otherwise use the protocol negotiated maximum. Never exceed the negotiated maximum.
|
|
int readBufferSize = Math.Min(_connectionParameters.ReadBufferSize ?? (int)_smb2Client.MaxReadSize, (int)_smb2Client.MaxReadSize);
|
|
status = _smbFileStore.ReadFile(out data, fileHandle, bytesRead, readBufferSize);
|
|
if (status != NTStatus.STATUS_SUCCESS && status != NTStatus.STATUS_END_OF_FILE)
|
|
throw new UserInformationException($"{LC.L("Failed to read file on GetAsync")} {filename} with status {status.ToString()}", "FileReadError");
|
|
|
|
if (status == NTStatus.STATUS_END_OF_FILE || data.Length == 0)
|
|
break;
|
|
|
|
bytesRead += data.Length;
|
|
await timeoutStream.WriteAsync(data, 0, data.Length, cancellationToken);
|
|
}
|
|
|
|
await timeoutStream.FlushAsync(cancellationToken);
|
|
|
|
if (fileHandle != null)
|
|
{
|
|
status = _smbFileStore.CloseFile(fileHandle);
|
|
if (status != NTStatus.STATUS_SUCCESS)
|
|
throw new UserInformationException($"{LC.L("Failed to close file handle on GetAsync")} {filename} with status {status.ToString()}", "HandleCloseError");
|
|
}
|
|
}
|
|
else if (status == NTStatus.STATUS_OBJECT_NAME_NOT_FOUND)
|
|
throw new FileMissingException($"{LC.L("The requested file does not exist")} {filename}");
|
|
else if (status == NTStatus.STATUS_OBJECT_PATH_NOT_FOUND)
|
|
throw new FolderMissingException();
|
|
else
|
|
throw new UserInformationException(
|
|
$"{LC.L("Failed to open file with error")} {filename} with status {status}",
|
|
"FileOpenError");
|
|
}
|
|
finally
|
|
{
|
|
_semaphore.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes file in the remote share and path specified in the connection parameters reading
|
|
/// from the sourceStream provided.
|
|
/// </summary>
|
|
/// <param name="filename"></param>
|
|
/// <param name="sourceStream"></param>
|
|
/// <param name="cancellationToken"></param>
|
|
/// <exception cref="Exception"></exception>
|
|
public async Task PutAsync(string filename, Stream sourceStream, CancellationToken cancellationToken)
|
|
{
|
|
await _semaphore.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
(var status, var fileStatus, var fileHandle) = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancellationToken, _ =>
|
|
{
|
|
var status = _smbFileStore.CreateFile(out var fileHandle, out var fileStatus,
|
|
NormalizeSlashes(Path.Combine(_connectionParameters.Path, filename)),
|
|
AccessMask.GENERIC_WRITE | AccessMask.SYNCHRONIZE,
|
|
FileAttributes.Normal, ShareAccess.None,
|
|
CreateDisposition.FILE_SUPERSEDE,
|
|
CreateOptions.FILE_NON_DIRECTORY_FILE | CreateOptions.FILE_SYNCHRONOUS_IO_ALERT,
|
|
null);
|
|
return (status, fileStatus, fileHandle);
|
|
}).ConfigureAwait(false);
|
|
|
|
if (status == NTStatus.STATUS_SUCCESS)
|
|
{
|
|
// Use the provided write buffer size if set, otherwise use the protocol negotiated maximum. Never exceed the negotiated maximum.
|
|
var buffer = new byte[Math.Min(_connectionParameters.WriteBufferSize ?? (int)_smb2Client.MaxWriteSize, _smb2Client.MaxWriteSize)];
|
|
int bytesRead;
|
|
int numberOfBytesWritten;
|
|
int offset = 0;
|
|
using var timeoutStream = sourceStream.ObserveReadTimeout(_timeouts.ReadWriteTimeout, false);
|
|
while (!cancellationToken.IsCancellationRequested && timeoutStream.Position < timeoutStream.Length)
|
|
{
|
|
bytesRead = await timeoutStream.ReadAsync(buffer, cancellationToken);
|
|
if (bytesRead == 0)
|
|
break;
|
|
status = _smbFileStore.WriteFile(out numberOfBytesWritten, fileHandle, offset, buffer.Take(bytesRead).ToArray());
|
|
offset += numberOfBytesWritten;
|
|
if (numberOfBytesWritten != bytesRead)
|
|
throw new UserInformationException(LC.L("Failed to write to file, difference between bytes read and bytes written"), "HandleWriteError");
|
|
if (status != NTStatus.STATUS_SUCCESS)
|
|
throw new UserInformationException($"{LC.L("Failed to write file on Putasync")} {filename} with status {status.ToString()}", "HandleWriteError");
|
|
}
|
|
if (fileHandle != null)
|
|
{
|
|
status = _smbFileStore.CloseFile(fileHandle);
|
|
if (status != NTStatus.STATUS_SUCCESS)
|
|
throw new UserInformationException($"{LC.L("Failed to close file handle on PutAsync")} {filename} with status {status.ToString()}", "HandleCloseError");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
throw new UserInformationException($"{LC.L("Failed to create file for writing")} {filename} with status {status.ToString()}", "FileCreateError");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_semaphore.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Normalizes paths to use backslashes (for Windows shares compatibility) and removes trailing slashes.
|
|
///
|
|
/// Samba deals with \ and / in paths, but Windows shares require backslashes.
|
|
/// </summary>
|
|
/// <param name="path">Path to be normalized</param>
|
|
/// <returns></returns>
|
|
private string NormalizeSlashes(string path)
|
|
{
|
|
return path.Replace('/', '\\').TrimEnd('\\');
|
|
}
|
|
|
|
/// <summary>
|
|
/// Synchronously dispose the resources.
|
|
/// </summary>
|
|
public void Dispose()
|
|
{
|
|
DisposeResourcesAsync().AsTask().GetAwaiter().GetResult();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Async dispose implementation
|
|
/// </summary>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await DisposeResourcesAsync();
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Inner dispose implementation
|
|
/// </summary>
|
|
private async ValueTask DisposeResourcesAsync()
|
|
{
|
|
if (_disposed) return;
|
|
|
|
await _semaphore.WaitAsync(); // Ensure no operations are in progress
|
|
try
|
|
{
|
|
if (_smbFileStore != null)
|
|
{
|
|
try
|
|
{
|
|
await Task.Run(() => _smbFileStore.Disconnect());
|
|
}
|
|
catch { /* Can be safely ignored */ }
|
|
}
|
|
|
|
if (_smb2Client != null)
|
|
{
|
|
try
|
|
{
|
|
await Task.Run(() => _smb2Client.Logoff());
|
|
await Task.Run(() => _smb2Client.Disconnect());
|
|
}
|
|
catch { /* Can be safely ignored */ }
|
|
}
|
|
|
|
_semaphore.Dispose();
|
|
}
|
|
finally
|
|
{
|
|
_disposed = true;
|
|
}
|
|
}
|
|
}
|