// 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. #nullable enable using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Duplicati.Library.Backend.CIFS.Model; using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; using Duplicati.Library.Localization.Short; using SMBLibrary; using SMBLibrary.Client; using FileAttributes = SMBLibrary.FileAttributes; namespace Duplicati.Library.Backend.CIFS; /// /// 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 = new(); /// /// Shared fileStore object. /// private readonly ISMBFileStore _smbFileStore; /// /// Connection parameters /// private readonly SMBConnectionParameters _connectionParameters; /// /// The semaphore to ensure that only one operation is performed at a time. /// private readonly SemaphoreSlim _semaphore = new(1, 1); 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 /// Exception to be displayed to user public SMBShareConnection(SMBConnectionParameters connectionParameters) { _connectionParameters = connectionParameters; if (!_smb2Client.Connect(connectionParameters.ServerName, connectionParameters.TransportType)) throw new UserInformationException($"{LC.L("Failed to connect to server")} {connectionParameters.ServerName}", "ConnectionError"); var status = _smb2Client.Login(connectionParameters.AuthDomain ?? "", connectionParameters.AuthUser ?? "", connectionParameters.AuthPassword ?? ""); if (status != NTStatus.STATUS_SUCCESS) throw new UserInformationException($"{LC.L("Failed to authenticate to server")} {connectionParameters.ServerName} with status {status}", "ConnectionError"); _smbFileStore = _smb2Client.TreeConnect(connectionParameters.ShareName, out status); if (status != NTStatus.STATUS_SUCCESS) throw new UserInformationException($"{LC.L("Failed to connect to share")} {connectionParameters.ShareName} with status {status}", "ConnectionError"); } /// /// 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 { NTStatus status; object fileHandle; FileStatus fileStatus; status = _smbFileStore.CreateFile(out fileHandle, out 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"); } } 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 string linuxNormalizedPath = path.Replace('/', '\\').TrimEnd('\\'); string currentPath = ""; foreach (string part in linuxNormalizedPath.Split('\\', StringSplitOptions.RemoveEmptyEntries)) { if (string.IsNullOrWhiteSpace(part) || part == ".") continue; currentPath = currentPath.Length == 0 ? part : $"{currentPath}\\{part}"; object? fileHandle = null; try { NTStatus status = _smbFileStore.CreateFile( out fileHandle, out FileStatus 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"); } 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 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 .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(); } } /// /// 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 { object? fileHandle; FileStatus fileStatus; NTStatus status = _smbFileStore.CreateFile(out fileHandle, out 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); if (status == NTStatus.STATUS_SUCCESS || fileStatus != FileStatus.FILE_DOES_NOT_EXIST) { byte[] data; long bytesRead = 0; 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 destinationStream.WriteAsync(data, 0, data.Length, cancellationToken); } await destinationStream.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.ToString()}", "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 { object fileHandle; FileStatus fileStatus; NTStatus status = _smbFileStore.CreateFile(out fileHandle, out 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); if (status == NTStatus.STATUS_SUCCESS) { // Use the provided write buffer size if set, otherwise use the protocol negotiated maximum. Never exceed the negotiated maximum. byte[] buffer = new byte[Math.Min(_connectionParameters.WriteBufferSize ?? (int)_smb2Client.MaxWriteSize, _smb2Client.MaxWriteSize)]; int bytesRead; int numberOfBytesWritten; int offset = 0; while (!cancellationToken.IsCancellationRequested && sourceStream.Position < sourceStream.Length) { bytesRead = await sourceStream.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('\\'); } /// /// 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; } } }