// 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.Net; using System.Net.Security; using System.Runtime.CompilerServices; using System.Security.Authentication; using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; using Duplicati.Library.Logging; using Duplicati.Library.Utility; using Duplicati.Library.Utility.Options; using FluentFTP; using FluentFTP.Client.BaseClient; using FluentFTP.Exceptions; using CoreUtility = Duplicati.Library.Utility.Utility; using Uri = System.Uri; namespace Duplicati.Library.Backend { /// /// The unified FTP backend which uses the FluentFTP library. /// /// In previous versions, this was being exposed as AlternateFTPBackend, whist the FTP backend was /// using the System.Net.FtpWebRequest class which is now deprecated. /// /// To provide a transparent upgrade path, the AlternateFTPBackend now inherits from this class, /// but overides the default configuration values to match the old names(prefixed with a) and the backedn /// name being "aftp" rather than ftp. /// public class FTP : IStreamingBackend, IRenameEnabledBackend { private static readonly string LogTag = Log.LogTagFromType(typeof(FTP)); /// /// The credentials used to authenticate with the FTP server /// private readonly NetworkCredential? _userInfo; /// /// The default data connection type /// private const FtpDataConnectionType DEFAULT_DATA_CONNECTION_TYPE = FtpDataConnectionType.AutoPassive; /// /// The default encryption mode /// private const FtpEncryptionMode DEFAULT_ENCRYPTION_MODE = FtpEncryptionMode.None; /// /// The default SSL protocols /// private static readonly SslProtocols DEFAULT_SSL_PROTOCOLS = SslProtocols.None; // NOTE: None means "use system default" /// /// Configuration key for the flag to ignore the PureFTPd limit issue, suppressing the exceptions. /// /// Chosen not to have ftp prefix to be agnostic between aftp and ftp /// protected virtual string CONFIG_KEY_FTP_IGNORE_PUREFTP => "ignore-pureftpd-limit-issue"; /// /// The configuration key for the FTP encryption mode /// protected virtual string CONFIG_KEY_FTP_ENCRYPTION_MODE => "ftp-encryption-mode"; /// /// The configuration key for the FTP data connection type /// protected virtual string CONFIG_KEY_FTP_DATA_CONNECTION_TYPE => "ftp-data-connection-type"; /// /// The configuration key for the FTP SSL protocols /// protected virtual string CONFIG_KEY_FTP_SSL_PROTOCOLS => "ftp-ssl-protocols"; /// /// The configuration key for the FTP upload delay /// protected virtual string CONFIG_KEY_FTP_UPLOAD_DELAY => "ftp-upload-delay"; /// /// The configuration key for the FTP log to console /// protected virtual string CONFIG_KEY_FTP_LOGTOCONSOLE => "ftp-log-to-console"; /// /// The configuration key for the FTP log private info to console /// protected virtual string CONFIG_KEY_FTP_LOGPRIVATEINFOTOCONSOLE => "ftp-log-privateinfo-to-console"; /// /// The configuration key for the FTP log to console /// protected virtual string CONFIG_KEY_FTP_LOGDIAGNOSTICS => "ftp-log-diagnostics"; /// /// The configuration key for the FTP absolute paths option /// protected virtual string CONFIG_KEY_FTP_ABSOLUTE_PATH => "ftp-absolute-path"; /// /// The configuration key for the FTP relative path option /// protected virtual string CONFIG_KEY_FTP_RELATIVE_PATH => "ftp-relative-path"; /// /// The configuration key for the FTP use CWD names option /// protected virtual string CONFIG_KEY_FTP_USE_CWD_NAMES => "ftp-use-cwd-names"; /// /// The configuration key for the disable upload verify option /// protected virtual string CONFIG_KEY_DISABLE_UPLOAD_VERIFY => "disable-upload-verify"; // The following keys are private because they are irrelevant for inheritors and are here for backwards compatibility /// /// The configuration key for the legacy FTP passive mode /// private static string CONFIG_KEY_FTP_LEGACY_FTPPASSIVE => "ftp-passive"; /// /// The configuration key for the legacy FTP active mode /// private static string CONFIG_KEY_FTP_LEGACY_FTPREGULAR => "ftp-regular"; /// /// The configuration key for the legacy use SSL option /// private static string CONFIG_KEY_FTP_LEGACY_USESSL => "use-ssl"; /// /// The default data connection type as a string /// protected static readonly string DEFAULT_DATA_CONNECTION_TYPE_STRING = DEFAULT_DATA_CONNECTION_TYPE.ToString(); /// /// The default encryption mode as a string /// protected static readonly string DEFAULT_ENCRYPTION_MODE_STRING = DEFAULT_ENCRYPTION_MODE.ToString(); /// /// The default SSL protocols as a string /// protected static readonly string DEFAULT_SSL_PROTOCOLS_STRING = DEFAULT_SSL_PROTOCOLS.ToString(); /// /// The default upload delay as a string /// protected static readonly string DEFAULT_UPLOAD_DELAY_STRING = "0s"; /// /// The URL of the FTP server /// private readonly Uri _url; /// /// The flag to indicate if the list verify option is enabled /// private readonly bool _listVerify = true; /// /// The flag to indicate if relative paths are used /// private readonly bool _relativePaths = true; /// /// The flag to indicate if the CWD strategy is used /// private readonly bool _useCwdNames = false; /// /// The FTP configuration /// private readonly FtpConfig _ftpConfig; /// /// The wait time after each upload before checking the file size /// private readonly TimeSpan _uploadWaitTime; /// /// Flag to ignore the PureFTPd limit issue, suppressing the exceptions. /// private readonly bool _IgnorePureFTPdLimitIssue; /// /// The flag to indicate if the dialog should be logged to the console /// private readonly bool _logToConsole; /// /// The flag to indicate if private information should be logged to the console /// private readonly bool _logPrivateInfoToConsole; /// /// The flag to indicate if diagnostics information should be logged /// private readonly bool _diagnosticsLog; /// /// The ssl certificate options to use /// private readonly SslOptionsHelper.SslCertificateOptions _sslOptions; /// /// The timeout options to use /// private readonly TimeoutOptionsHelper.Timeouts _timeouts; /// /// The localized name to display for this backend /// public virtual string DisplayName => Strings.DisplayName; /// /// The protocol key, e.g. ftp, http or ssh /// public virtual string ProtocolKey => "ftp"; /// /// The client instance /// private AsyncFtpClient? _client; /// /// The server initial working directory /// private string? _initialCwd; /// public virtual IList SupportedCommands => [ .. AuthOptionsHelper.GetOptions(), new CommandLineArgument(CONFIG_KEY_DISABLE_UPLOAD_VERIFY, CommandLineArgument.ArgumentType.Boolean, Strings.DescriptionDisableUploadVerifyShort, Strings.DescriptionDisableUploadVerifyLong), new CommandLineArgument(CONFIG_KEY_FTP_ABSOLUTE_PATH, CommandLineArgument.ArgumentType.Boolean, Strings.DescriptionAbsolutePathShort, Strings.DescriptionAbsolutePathLong), new CommandLineArgument(CONFIG_KEY_FTP_USE_CWD_NAMES, CommandLineArgument.ArgumentType.Boolean, Strings.DescriptionUseCwdNamesShort, Strings.DescriptionUseCwdNamesLong), new CommandLineArgument(CONFIG_KEY_FTP_DATA_CONNECTION_TYPE, CommandLineArgument.ArgumentType.Enumeration, Strings.DescriptionFtpDataConnectionTypeShort, Strings.DescriptionFtpDataConnectionTypeLong, DEFAULT_DATA_CONNECTION_TYPE_STRING, null, Enum.GetNames(typeof(FtpDataConnectionType))), new CommandLineArgument(CONFIG_KEY_FTP_ENCRYPTION_MODE, CommandLineArgument.ArgumentType.Enumeration, Strings.DescriptionFtpEncryptionModeShort, Strings.DescriptionFtpEncryptionModeLong, DEFAULT_ENCRYPTION_MODE_STRING, null, Enum.GetNames(typeof(FtpEncryptionMode))), new CommandLineArgument(CONFIG_KEY_FTP_SSL_PROTOCOLS, CommandLineArgument.ArgumentType.Flags, Strings.DescriptionSslProtocolsShort, Strings.DescriptionSslProtocolsLong, DEFAULT_SSL_PROTOCOLS_STRING, null, Enum.GetNames(typeof(SslProtocols))), new CommandLineArgument(CONFIG_KEY_FTP_UPLOAD_DELAY, CommandLineArgument.ArgumentType.Timespan, Strings.DescriptionUploadDelayShort, Strings.DescriptionUploadDelayLong, DEFAULT_UPLOAD_DELAY_STRING), new CommandLineArgument(CONFIG_KEY_FTP_LOGTOCONSOLE, CommandLineArgument.ArgumentType.Boolean, Strings.DescriptionLogToConsoleShort, Strings.DescriptionLogToConsoleLong), new CommandLineArgument(CONFIG_KEY_FTP_LOGPRIVATEINFOTOCONSOLE, CommandLineArgument.ArgumentType.Boolean, Strings.DescriptionLogPrivateInfoToConsoleShort, Strings.DescriptionLogPrivateInfoToConsoleLong, "false"), new CommandLineArgument(CONFIG_KEY_FTP_LOGDIAGNOSTICS, CommandLineArgument.ArgumentType.Boolean, Strings.DescriptionLogDiagnosticsShort, Strings.DescriptionLogDiagnosticsLong), new CommandLineArgument(CONFIG_KEY_FTP_LEGACY_FTPPASSIVE, CommandLineArgument.ArgumentType.Boolean, Strings.DescriptionFTPPassiveShort, Strings.DescriptionFTPPassiveLong, "false", null, null, Strings.FtpPassiveDeprecated), new CommandLineArgument(CONFIG_KEY_FTP_LEGACY_FTPREGULAR, CommandLineArgument.ArgumentType.Boolean, Strings.DescriptionFTPActiveShort, Strings.DescriptionFTPActiveLong, "true", null, null, Strings.FtpActiveDeprecated), new CommandLineArgument(CONFIG_KEY_FTP_LEGACY_USESSL, CommandLineArgument.ArgumentType.Boolean, Strings.DescriptionUseSSLShort, Strings.DescriptionUseSSLLong, "false", null, null, Strings.UseSslDeprecated), new CommandLineArgument(CONFIG_KEY_FTP_IGNORE_PUREFTP, CommandLineArgument.ArgumentType.Boolean, Strings.DescriptionIgnorePureFTPShort, Strings.DescriptionIgnorePureFTPLong, "false"), .. SslOptionsHelper.GetCertOnlyOptions(), .. TimeoutOptionsHelper.GetOptions(), ]; /// /// Initialize a new instance. /// public FTP() { // TODO: Remove this constructor once static properties are introduced on IBackend _sslOptions = null!; _timeouts = null!; _ftpConfig = null!; _url = null!; _client = null!; } /// /// Initialize a new instance/ /// /// Configured url. /// Configured options. cannot be null. public FTP(string url, Dictionary options) { _sslOptions = SslOptionsHelper.Parse(options); var u = new Utility.Uri(url); u.RequireHost(); var auth = AuthOptionsHelper.Parse(options, u); if (auth.HasUsername) { _userInfo = new NetworkCredential() { UserName = auth.Username, Domain = "" }; if (auth.HasPassword) _userInfo.Password = auth.Password; } var parsedurl = u.SetScheme("ftp").SetQuery(null).SetCredentials(null, null).ToString(); parsedurl = Util.AppendDirSeparator(parsedurl, "/"); _url = new Uri(parsedurl); _listVerify = !CoreUtility.ParseBoolOption(options, CONFIG_KEY_DISABLE_UPLOAD_VERIFY); _relativePaths = ProtocolKey == "ftp" ? !CoreUtility.ParseBoolOption(options, CONFIG_KEY_FTP_ABSOLUTE_PATH) : CoreUtility.ParseBoolOption(options, CONFIG_KEY_FTP_RELATIVE_PATH); _useCwdNames = CoreUtility.ParseBoolOption(options, CONFIG_KEY_FTP_USE_CWD_NAMES); if (options.TryGetValue(CONFIG_KEY_FTP_UPLOAD_DELAY, out var uploadWaitTimeString) && !string.IsNullOrWhiteSpace(uploadWaitTimeString)) _uploadWaitTime = Timeparser.ParseTimeSpan(uploadWaitTimeString); var dataConnectionType = CoreUtility.ParseEnumOption(options, CONFIG_KEY_FTP_DATA_CONNECTION_TYPE, DEFAULT_DATA_CONNECTION_TYPE); var encryptionMode = CoreUtility.ParseEnumOption(options, CONFIG_KEY_FTP_ENCRYPTION_MODE, DEFAULT_ENCRYPTION_MODE); var sslProtocols = CoreUtility.ParseFlagsOption(options, CONFIG_KEY_FTP_SSL_PROTOCOLS, DEFAULT_SSL_PROTOCOLS); // Process options of the legacy FTP backend if (ProtocolKey == "ftp") { // To mirror the behavior of existing backups, we need to check the legacy options // This flag takes precedence over ftp-data-connection-type if (CoreUtility.ParseBoolOption(options, CONFIG_KEY_FTP_LEGACY_FTPPASSIVE)) dataConnectionType = FtpDataConnectionType.AutoPassive; // This flag takes precedence over the ftp-passive flag if (CoreUtility.ParseBoolOption(options, CONFIG_KEY_FTP_LEGACY_FTPREGULAR)) dataConnectionType = FtpDataConnectionType.AutoActive; // When using legacy useSSL option, the encryption is set to automatic and the SSL protocols are set to none // (None meaning the OS will choose the appropriate protocol) if (CoreUtility.ParseBoolOption(options, CONFIG_KEY_FTP_LEGACY_USESSL)) { sslProtocols = SslProtocols.None; encryptionMode = FtpEncryptionMode.Explicit; } } _logToConsole = CoreUtility.ParseBoolOption(options, CONFIG_KEY_FTP_LOGTOCONSOLE); _logPrivateInfoToConsole = CoreUtility.ParseBoolOption(options, CONFIG_KEY_FTP_LOGPRIVATEINFOTOCONSOLE); _diagnosticsLog = CoreUtility.ParseBoolOption(options, CONFIG_KEY_FTP_LOGDIAGNOSTICS); _timeouts = TimeoutOptionsHelper.Parse(options); _ftpConfig = new FtpConfig { DataConnectionType = dataConnectionType, EncryptionMode = encryptionMode, SslProtocols = sslProtocols, LogToConsole = _logToConsole, ValidateAnyCertificate = _sslOptions.AcceptAllCertificates, Noop = true }; _IgnorePureFTPdLimitIssue = CoreUtility.ParseBoolOption(options, CONFIG_KEY_FTP_IGNORE_PUREFTP); if (_logPrivateInfoToConsole) _ftpConfig.LogHost = _ftpConfig.LogPassword = _ftpConfig.LogUserName = true; } /// public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { FtpListItem[] items; try { var client = await CreateClient(cancelToken).ConfigureAwait(false); var remotePath = PreparePathForClient(null); items = await Utility.Utility.WithTimeout(_timeouts.ListTimeout, cancelToken, ct => client.GetListing(remotePath, FtpListOption.Modify | FtpListOption.Size, ct) ).ConfigureAwait(false); if (client.ServerType == FtpServer.PureFTPd) { // If the list was truncated an exception has to be raised as the listing is incomplete and can lead to misleading backup/restore results if (client.LastReplies.Any(x => x.Code == "226" && x.Message.Contains("truncated", StringComparison.InvariantCultureIgnoreCase))) throw new UserInformationException("PureFTPd server effectively truncated the listing due to LimitRecursion parameter - please check documentation for more information", "PureFTPdTruncatedListing"); // If no truncation occured and the ignore flag is not set, issue an advisory message if (!_IgnorePureFTPdLimitIssue) Log.WriteWarningMessage(LogTag, "PureFTPdIssue", null, Strings.DescriptionIgnorePureFTPLong); } } catch (Exception e) { TranslateException(null, e); throw; } foreach (var item in items) { switch (item.Type) { case FtpObjectType.Directory: if (item.Name == "." || item.Name == "..") continue; yield return new FileEntry(item.Name, -1, new DateTime(), item.Modified) { IsFolder = true, }; break; case FtpObjectType.File: yield return new FileEntry(item.Name, item.Size, new DateTime(), item.Modified); break; case FtpObjectType.Link: { if (item.Name == "." || item.Name == "..") continue; if (item.LinkObject != null) { switch (item.LinkObject.Type) { case FtpObjectType.Directory: if (item.Name == "." || item.Name == "..") continue; yield return new FileEntry(item.Name, -1, new DateTime(), item.Modified) { IsFolder = true, }; break; case FtpObjectType.File: yield return new FileEntry(item.Name, item.Size, new DateTime(), item.Modified); break; } } break; } } } } /// public async Task PutAsync(string remotename, Stream input, CancellationToken cancelToken) { try { var streamLen = -1L; var client = await CreateClient(cancelToken).ConfigureAwait(false); var clientRemoteName = PreparePathForClient(remotename); try { streamLen = input.Length; } catch (NotSupportedException) { } using var timeoutStream = input.ObserveReadTimeout(_timeouts.ReadWriteTimeout, false); var status = await client.UploadStream(timeoutStream, clientRemoteName, createRemoteDir: false, token: cancelToken, progress: null).ConfigureAwait(false); if (status != FtpStatus.Success) throw new UserInformationException(Strings.ErrorWriteFile(remotename, $"Status is {status}"), "FtpPutFailure"); // Wait for the upload, if required if (_uploadWaitTime.Ticks > 0) Thread.Sleep(_uploadWaitTime); if (_listVerify) { // check remote file size; matching file size indicates completion var remoteSize = await client.GetFileSize(clientRemoteName, -1, cancelToken); if (streamLen != remoteSize) throw new UserInformationException(Strings.ListVerifySizeFailure(remotename, remoteSize, streamLen), "FtpListVerifySizeFailure"); } } catch (Exception e) { TranslateException(remotename, e); throw; } } /// public async Task PutAsync(string remotename, string localname, CancellationToken cancelToken) { await using var fs = File.Open(localname, FileMode.Open, FileAccess.Read, FileShare.Read); await PutAsync(remotename, fs, cancelToken); } /// public async Task GetAsync(string remotename, Stream output, CancellationToken cancelToken) { try { var client = await CreateClient(cancelToken).ConfigureAwait(false); var clientRemoteName = PreparePathForClient(remotename); await using var inputStream = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancelToken, ct => client.OpenRead(clientRemoteName, token: ct)).ConfigureAwait(false); await using var timeoutStream = inputStream.ObserveReadTimeout(_timeouts.ReadWriteTimeout); await CoreUtility.CopyStreamAsync(inputStream, output, false, cancelToken).ConfigureAwait(false); } catch (Exception e) { TranslateException(remotename, e); throw; } } /// public async Task GetAsync(string remotename, string localname, CancellationToken cancelToken) { await using FileStream fs = File.Open(localname, FileMode.Create, FileAccess.Write, FileShare.None); await GetAsync(remotename, fs, cancelToken).ConfigureAwait(false); } /// public async Task DeleteAsync(string remotename, CancellationToken cancelToken) { try { var client = await CreateClient(cancelToken).ConfigureAwait(false); var clientRemoteName = PreparePathForClient(remotename); await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancelToken, ct => client.DeleteFile(clientRemoteName, ct) ).ConfigureAwait(false); } catch (Exception e) { TranslateException(remotename, e); throw; } } /// public virtual string Description => Strings.Description; /// public Task GetDNSNamesAsync(CancellationToken cancelToken) => Task.FromResult(new[] { _url.Host }); /// public bool SupportsStreaming => true; /// public async Task TestAsync(CancellationToken cancellationToken) { // Start with a simple list and pureFTP detection try { var client = await CreateClient(cancellationToken).ConfigureAwait(false); await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancellationToken, async ct => { await ListAsync(cancellationToken).AnyAsync(cancellationToken).ConfigureAwait(false); }).ConfigureAwait(false); if (client.ServerType == FtpServer.PureFTPd && !_IgnorePureFTPdLimitIssue) throw new UserInformationException(Strings.DescriptionIgnorePureFTPLong, "PureFTPdDetected"); } catch (Exception e) { TranslateException(null, e); throw; } // Try to set the working directory to trigger a folder-not-found exception try { var client = await CreateClient(cancellationToken).ConfigureAwait(false); await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancellationToken, async ct => { if (!_useCwdNames) { var folderpath = PreparePathForClient(null); await client.SetWorkingDirectory(folderpath, ct).ConfigureAwait(false); } }).ConfigureAwait(false); } catch (Exception e) { TranslateException(null, e); throw; } // Test the read/write permissions in folder await this.TestReadWritePermissionsAsync(cancellationToken).ConfigureAwait(false); } /// public async Task CreateFolderAsync(CancellationToken cancellationToken) { try { var client = await CreateClient(cancellationToken, false).ConfigureAwait(false); var clientPath = PreparePathForClient(null, false); await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancellationToken, async ct => { // Try to create the directory if (_useCwdNames && clientPath.Contains('/') && clientPath != "/") { // Go to the parent folder and create the folder var parentPath = clientPath.Substring(0, clientPath.LastIndexOf('/')); var folderName = clientPath.Substring(clientPath.LastIndexOf('/') + 1); await client.SetWorkingDirectory(parentPath, ct).ConfigureAwait(false); await client.CreateDirectory(folderName, true, ct).ConfigureAwait(false); // Reset the client and check that it works _client = null; client = await CreateClient(ct).ConfigureAwait(false); var cwd = await client.GetWorkingDirectory(ct).ConfigureAwait(false); if (!string.Equals(cwd?.TrimEnd('/'), clientPath, StringComparison.OrdinalIgnoreCase)) throw new UserInformationException(Strings.ErrorCreateFolder(clientPath, cwd), "CreateFolderError"); } else { await client.CreateDirectory(clientPath, true, ct); } }).ConfigureAwait(false); } catch (Exception ex) { TranslateException(null, ex); throw; } } /// public void Dispose() { _client?.Dispose(); } /// /// Create a new FTP client, or return the existing one if it already exists. /// /// The cancellation token. /// A flag to override the CWD strategy. /// The FTP client. private async Task CreateClient(CancellationToken cancellationToken, bool? cwdFlag = null) { if (_client == null) { _client = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancellationToken, async ct => { var client = new AsyncFtpClient { Host = _url.Host, Port = _url.Port == -1 ? 21 : _url.Port, Credentials = _userInfo, Config = _ftpConfig, Logger = _diagnosticsLog ? new DiagnosticsLogger() : null }; client.ValidateCertificate += HandleValidateCertificate; await client.Connect(ct).ConfigureAwait(false); // Set up for relative paths if (_relativePaths) { _initialCwd = await client.GetWorkingDirectory(ct).ConfigureAwait(false); _initialCwd = _initialCwd?.TrimEnd('/'); } // Setup the initial working directory, if needed if (cwdFlag ?? _useCwdNames) { var clientPath = PreparePathForClient(null, false, client); await client.SetWorkingDirectory(clientPath, ct).ConfigureAwait(false); } return client; }).ConfigureAwait(false); } return _client; } /// /// Handle the certificate validation event. /// /// The FTP client. /// The event arguments. private void HandleValidateCertificate(BaseFtpClient control, FtpSslValidationEventArgs e) { if (e.PolicyErrors == SslPolicyErrors.None || _sslOptions.AcceptAllCertificates) { e.Accept = true; return; } e.Accept = false; try { var certHash = (_sslOptions.AcceptSpecificCertificateHashes != null && _sslOptions.AcceptSpecificCertificateHashes.Length > 0) ? e.Certificate?.GetCertHashString() : null; if (certHash != null && _sslOptions.AcceptSpecificCertificateHashes != null && _sslOptions.AcceptSpecificCertificateHashes.Any(hash => !string.IsNullOrEmpty(hash) && certHash.Equals(hash, StringComparison.OrdinalIgnoreCase))) e.Accept = true; } catch { } if (e.Accept == false && e.Certificate != null) throw new SslCertificateValidator.InvalidCertificateException(e.Certificate?.GetCertHashString() ?? "", e.PolicyErrors); } /// /// Prepare the path for the client. /// /// The path to prepare. /// A flag to override the CWD strategy. /// The FTP client, if not using the class instance. /// The prepared path. private string PreparePathForClient(string? path, bool? cwdFlag = null, AsyncFtpClient? client = null) { client = client ?? _client; if (cwdFlag ?? _useCwdNames) return string.IsNullOrWhiteSpace(path) ? string.Empty : Uri.UnescapeDataString(path); var remotePath = _url.AbsolutePath.TrimEnd('/'); if (_relativePaths) { if (client == null) throw new InvalidOperationException("Client not initialized"); if (!string.IsNullOrWhiteSpace(_initialCwd)) remotePath = _initialCwd + "/" + remotePath.TrimStart('/'); } if (string.IsNullOrEmpty(path)) return Uri.UnescapeDataString(remotePath); if (path.StartsWith("/", StringComparison.Ordinal)) return Uri.UnescapeDataString(path); return Uri.UnescapeDataString(remotePath + "/" + path); } /// /// Translate an exception to a more user-friendly exception. /// /// The filename provided. /// The exception to translate. private void TranslateException(string? filename, Exception ex) { if (ex.InnerException != null && (ex.InnerException is FtpCommandException || ex.InnerException is SslCertificateValidator.InvalidCertificateException)) ex = ex.InnerException; if (ex is FtpCommandException ftpEx && (ftpEx.CompletionCode == "550" || ftpEx.CompletionCode == "450")) { ex = string.IsNullOrWhiteSpace(filename) ? new FolderMissingException(Strings.MissingFolderError(_url.AbsolutePath, ftpEx.Message), ftpEx) : new FileMissingException(Strings.FileMissingError(filename, ftpEx.Message), ftpEx); throw ex; } if (ex is SslCertificateValidator.InvalidCertificateException) throw ex; } public async Task RenameAsync(string oldname, string newname, CancellationToken cancellationToken) { try { var client = await CreateClient(cancellationToken).ConfigureAwait(false); await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancellationToken, async ct => { var oldPath = PreparePathForClient(oldname, false, client); var newPath = PreparePathForClient(newname, false, client); await client.Rename(oldPath, newPath, ct).ConfigureAwait(false); }).ConfigureAwait(false); } catch (Exception e) { TranslateException(oldname, e); throw; } } private sealed class DiagnosticsLogger : IFtpLogger { private static readonly string LOGTAG = Logging.Log.LogTagFromType(); public void Log(FtpLogEntry entry) { var type = entry.Severity switch { FtpTraceLevel.Verbose => Logging.LogMessageType.Verbose, FtpTraceLevel.Info => Logging.LogMessageType.Information, FtpTraceLevel.Warn => Logging.LogMessageType.Warning, FtpTraceLevel.Error => Logging.LogMessageType.Error, _ => Logging.LogMessageType.Information }; Logging.Log.WriteMessage(type, LOGTAG, "FtpLogMessage", entry.Exception, entry.Message); } } } }