// 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 System; using Duplicati.Library.Interface; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Duplicati.Library.Backend.CIFS; using Duplicati.Library.Backend.CIFS.Model; using SMBLibrary; using Duplicati.Library.SourceProvider; using System.Runtime.CompilerServices; using Duplicati.Library.Common.IO; using System.Runtime.CompilerServices; namespace Duplicati.Library.Backend; /// /// Native CIFS/SMB Backend implementation /// public class CIFSBackend : IStreamingBackend, IFolderEnabledBackend { /// /// Implementation of interface property for the backend key /// public string ProtocolKey => "cifs"; /// /// Implementation of interface property for the backend display name /// public string DisplayName => Strings.CIFSBackend.DisplayName; /// /// Implementation of interface property for the backend description /// public string Description => Strings.CIFSBackend.Description; /// /// Hostname only (no ports or paths) to be used on DNS resolutions. /// private string _DnsName; /// /// Path separators (both Windows \ and unix /) to be used in path manipulation /// private static readonly char[] PATH_SEPARATORS = ['/', '\\']; /// /// Cache of parsed connection parameters /// private SMBConnectionParameters _connectionParameters; /// /// Shared connection between all methods to avoid re-authentication /// private SMBShareConnection _sharedConnection; /// /// Read buffer size for SMB operations (will be capped automatically by SMB negotiated values) /// private const string READ_BUFFER_SIZE_OPTION = "read-buffer-size"; /// /// Write buffer size for SMB operations (will be capped automatically by SMB negotiated values) /// private const string WRITE_BUFFER_SIZE_OPTION = "write-buffer-size"; /// /// Backend option for controlling the transport (directtcp or netbios) /// private const string TRANSPORT_OPTION = "transport"; /// /// Domain (complementary part of authentication) option /// private const string AUTH_DOMAIN_OPTION = "auth-domain"; /// /// Username for authentication /// private const string AUTH_USERNAME_OPTION = "auth-username"; /// /// Password for authentication /// private const string AUTH_PASSWORD_OPTION = "auth-password"; /// /// Defines the default transport to be used in CIFS connection /// private const string DEFAULT_TRANSPORT = "directtcp"; /// /// Mapping of transport string to SMBTransportType enum to be used in parsing the option string /// private readonly Dictionary _transportMap = new() { ["directtcp"] = SMBTransportType.DirectTCPTransport, ["netbios"] = SMBTransportType.NetBiosOverTCP }; /// /// Empty constructor is required for the backend to be loaded by the backend factory /// public CIFSBackend() { } /// /// Actual constructor for the backend that accepts the url and options /// /// URL in Duplicati Uri format /// options to be used in the backend public CIFSBackend(string url, Dictionary options) { if (string.IsNullOrEmpty(url)) throw new ArgumentNullException(nameof(url)); if (options == null) throw new ArgumentNullException(nameof(options)); var uri = new Utility.Uri(url); uri.RequireHost(); _DnsName = uri.Host; var input = uri.Path.TrimEnd('/'); var slashIndex = input.IndexOf('/'); // Find first slash to separate server and share if present. options.TryGetValue(AUTH_USERNAME_OPTION, out string authUsername); options.TryGetValue(AUTH_PASSWORD_OPTION, out string authPassword); options.TryGetValue(AUTH_DOMAIN_OPTION, out string authDomain); options.TryGetValue(TRANSPORT_OPTION, out string transport); int? readBufferSize = null, writeBufferSize = null; options.TryGetValue(READ_BUFFER_SIZE_OPTION, out string readBufferSizeConfig); if (!string.IsNullOrWhiteSpace(readBufferSizeConfig)) readBufferSize = Int32.TryParse(readBufferSizeConfig, out int value) ? value : null; options.TryGetValue(WRITE_BUFFER_SIZE_OPTION, out string writeBufferSizeConfig); if (!string.IsNullOrWhiteSpace(writeBufferSizeConfig)) writeBufferSize = Int32.TryParse(readBufferSizeConfig, out int value) ? value : null; // Normalize to 10KB minimum buffers size readBufferSize = readBufferSize < 1024 * 10 ? null : readBufferSize; writeBufferSize = writeBufferSize < 1024 * 10 ? null : writeBufferSize; SMBTransportType transportType = _transportMap.TryGetValue( string.IsNullOrEmpty(transport) ? DEFAULT_TRANSPORT : transport.ToLower(), out SMBTransportType type) ? type : throw new UserInformationException($"Transport must be one of: {string.Join(", ", _transportMap.Keys)}", "CIFSConfig"); _connectionParameters = new SMBConnectionParameters( uri.Host, transportType, slashIndex >= 0 ? input[..slashIndex] : input, slashIndex >= 0 ? input[(slashIndex + 1)..] : "", authDomain, authUsername, authPassword, readBufferSize, writeBufferSize ); } /// /// Implementation of interface property to return supported command parameters /// public IList SupportedCommands => new List([ new CommandLineArgument(AUTH_PASSWORD_OPTION, CommandLineArgument.ArgumentType.Password, Strings.CIFSBackend.DescriptionAuthPasswordShort, Strings.CIFSBackend.DescriptionAuthPasswordLong), new CommandLineArgument(AUTH_USERNAME_OPTION, CommandLineArgument.ArgumentType.String, Strings.CIFSBackend.DescriptionAuthUsernameShort, Strings.CIFSBackend.DescriptionAuthUsernameLong), new CommandLineArgument(AUTH_DOMAIN_OPTION, CommandLineArgument.ArgumentType.String, Strings.CIFSBackend.DescriptionAuthDomainShort, Strings.CIFSBackend.DescriptionAuthDomainLong), new CommandLineArgument(TRANSPORT_OPTION, CommandLineArgument.ArgumentType.Enumeration, Strings.Options.TransportShort, Strings.Options.TransportLong, DEFAULT_TRANSPORT, null, _transportMap.Keys.ToArray()), new CommandLineArgument(READ_BUFFER_SIZE_OPTION, CommandLineArgument.ArgumentType.String, Strings.Options.DescriptionReadBufferSizeShort, Strings.Options.DescriptionReadBufferSizeLong), new CommandLineArgument(WRITE_BUFFER_SIZE_OPTION, CommandLineArgument.ArgumentType.String, Strings.Options.DescriptionWriteBufferSizeShort, Strings.Options.DescriptionWriteBufferSizeLong) ]); /// /// Implementation of interface method for listing remote folder contents /// /// List of IFileEntry with directory listing result public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancellationToken) { foreach (var v in await GetConnection().ListAsync(_connectionParameters.Path, cancellationToken).ConfigureAwait(false)) yield return v; } /// /// Upload files to remote location /// /// Filename at remote location /// Filename to read from /// CancellationToken that is combined with internal timeout token /// FileMissingException when file is not found /// Exceptions arising from either code execution public async Task PutAsync(string remotename, string localname, CancellationToken cancellationToken) { await using var fs = File.Open(localname, FileMode.Open, FileAccess.Read, FileShare.Read); await PutAsync(remotename, fs, cancellationToken).ConfigureAwait(false); } /// /// Upload files to remote location /// /// Filename at remote location /// Stream to read from /// CancellationToken that is combined with internal timeout token /// FileMissingException when file is not found /// Exceptions arising from either code execution public async Task PutAsync(string remotename, Stream input, CancellationToken cancellationToken) { await GetConnection().PutAsync(remotename, input, cancellationToken).ConfigureAwait(false); } /// /// Download files from remote /// /// Filename at remote location /// Local filename to write to /// CancellationToken that is combined with internal timeout token /// FileMissingException when file is not found /// Exceptions arising from either code execution or FileMissingException public async Task GetAsync(string remotename, string localname, CancellationToken cancellationToken) { await using var fs = File.Open(localname, FileMode.Create, FileAccess.Write, FileShare.None); await GetAsync(remotename, fs, cancellationToken).ConfigureAwait(false); } /// /// Download files from remote /// /// Filename at remote location /// Destination stream to write to /// CancellationToken that is combined with internal timeout token /// FileMissingException when file is not found /// Exceptions arising from either code execution or FileMissingException public async Task GetAsync(string remotename, Stream output, CancellationToken cancellationToken) { await GetConnection().GetAsync(remotename, output, cancellationToken).ConfigureAwait(false); } /// /// Delete remote file if it exists, if not, throws FileMissingException /// /// filename to be deleted on the remote /// CancellationToken that is combined with internal timeout token /// /// FileMissingException when file is not found /// Exceptions arising from either code execution or business logic errors public async Task DeleteAsync(string remotename, CancellationToken cancellationToken) { await GetConnection().DeleteAsync(remotename, cancellationToken).ConfigureAwait(false); } /// /// Implementation of interface function to return hosnames used by the backend /// /// CancellationToken, in this call not used. /// public Task GetDNSNamesAsync(CancellationToken cancellationToken) => Task.FromResult(new[] { _DnsName ?? string.Empty }); /// /// Tests backend connectivity by verifying the configured path exists /// /// The cancellation token (not used) /// Thrown when configured path does not exist public async Task TestAsync(CancellationToken cancellationToken) { // This will throw an exception if the folder is missing await GetConnection().ListAsync(_connectionParameters.Path, cancellationToken).ConfigureAwait(false); } /// /// Creates the configured remote folder path if it doesn't exist /// /// CancellationToken that will be combined with internal timeout token /// Task representing the asynchronous operation /// Thrown when folder creation fails public async Task CreateFolderAsync(CancellationToken cancellationToken) { var pathParts = _connectionParameters.Path? .Split(PATH_SEPARATORS, StringSplitOptions.RemoveEmptyEntries); if (pathParts == null || pathParts.Length == 0) return; await GetConnection().CreateFolderAsync(_connectionParameters.Path, cancellationToken) .ConfigureAwait(false); } /// /// Gets or creates a shared SMB connection /// /// An SMB connection that can be used for file operations private SMBShareConnection GetConnection() => _sharedConnection ??= new SMBShareConnection(_connectionParameters); /// /// Implementation of Dispose pattern enforced by interface /// public void Dispose() { try { _sharedConnection?.Dispose(); } catch (Exception ex) { // Log the exception but don't rethrow since we're in Dispose System.Diagnostics.Debug.WriteLine($"Error disposing CIFS connection: {ex.Message}"); } } /// public async IAsyncEnumerable ListAsync(string path, [EnumeratorCancellation] CancellationToken cancellationToken) { var sourcePath = _connectionParameters.Path; if (!string.IsNullOrWhiteSpace(sourcePath)) sourcePath = Util.AppendDirSeparator(sourcePath, "/"); foreach (var v in await GetConnection().ListAsync(sourcePath + BackendSourceFileEntry.NormalizePathTo(path, '/'), cancellationToken).ConfigureAwait(false)) if (v.Name != "." && v.Name != "..") yield return v; } /// public Task GetEntryAsync(string path, CancellationToken cancellationToken) => Task.FromResult(null); }