// 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; /// /// Class the wraps the SMB connection and file store objects, handling the connection, /// logon and logoff operations as well as safely disposing resources. /// public class SMBShareConnection : IDisposable, IAsyncDisposable { /// /// SMBConnection client /// private readonly SMB2Client _smb2Client; /// /// Shared fileStore object. /// private readonly ISMBFileStore _smbFileStore; /// /// Connection parameters /// private readonly SMBConnectionParameters _connectionParameters; /// /// The timeouts to use for operations. /// private readonly TimeoutOptionsHelper.Timeouts _timeouts; /// /// The semaphore to ensure that only one operation is performed at a time. /// private readonly SemaphoreSlim _semaphore = new(1, 1); /// /// Flag to indicate if the object has been disposed. /// private bool _disposed; /// /// This constructor will connect to the server and share as specified in the connection parameters. /// /// It throws specific exceptions for connection and authentication failures. /// /// Connection Parameters /// Timeouts to use for operations private SMBShareConnection(SMB2Client client, ISMBFileStore fileStore, SMBConnectionParameters connectionParameters, TimeoutOptionsHelper.Timeouts timeouts) { _connectionParameters = connectionParameters; _timeouts = timeouts; _smbFileStore = fileStore; _smb2Client = client; } /// /// Creates a new SMBShareConnection object asynchronously. /// /// The connection parameters /// The timeouts to use for operations /// The cancellation token /// The SMBShareConnection object /// Exception to be displayed to user public static async Task 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); } /// /// Deletes the file specified in the path specified in the connection parameters. /// /// Filename to be deleted /// The cancellation token /// Exception to be displayed to user 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(); } } /// /// Create the folder structure specified in the path. /// /// Path to create /// Cancellation Token /// Exception to be displayed to user 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(); } } /// /// Lists the folder contents of the share and path specified in the connection parameters. /// /// Path to list /// Cancellation Token /// Exception to be displayed to user public async Task> 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 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() .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 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(); } } /// /// Read a file from source and write it to the destination stream. /// /// Filename to be read /// Destination stream to write contents read from source /// Cancellation Token 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(); } } /// /// Writes file in the remote share and path specified in the connection parameters reading /// from the sourceStream provided. /// /// /// /// /// 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(); } } /// /// Normalizes paths to use backslashes (for Windows shares compatibility) and removes trailing slashes. /// /// Samba deals with \ and / in paths, but Windows shares require backslashes. /// /// Path to be normalized /// private string NormalizeSlashes(string path) { return path.Replace('/', '\\').TrimEnd('\\'); } /// /// Renames a file. /// /// The old filename /// The new filename /// The cancellation token public async Task RenameAsync(string oldname, string newname, 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, oldname)), 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 fileRenameInformation = new FileRenameInformationType2 { ReplaceIfExists = true, FileName = NormalizeSlashes(Path.Combine(_connectionParameters.Path, newname)) }; status = _smbFileStore.SetFileInformation(fileHandle, fileRenameInformation); if (status != NTStatus.STATUS_SUCCESS) throw new UserInformationException($"{LC.L("Failed to rename file on RenameAsync")} with status {status}", "RenameFileError"); status = _smbFileStore.CloseFile(fileHandle); if (status != NTStatus.STATUS_SUCCESS) throw new UserInformationException($"{LC.L("Failed to close file on RenameAsync")} with status {status}", "CloseFileError"); } }).ConfigureAwait(false); } finally { _semaphore.Release(); } } /// /// Synchronously dispose the resources. /// public void Dispose() { DisposeResourcesAsync().AsTask().GetAwaiter().GetResult(); } /// /// Async dispose implementation /// public async ValueTask DisposeAsync() { await DisposeResourcesAsync(); GC.SuppressFinalize(this); } /// /// Inner dispose implementation /// 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; } } }