// 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.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Net; using System.Net.Security; using System.Runtime.CompilerServices; using System.Security.Authentication; using System.Text; using System.Threading; using System.Threading.Tasks; using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; using Duplicati.Library.Utility; 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 { /// /// The credentials used to authenticate with the FTP server /// private readonly NetworkCredential? _userInfo; /// /// Th option used to accept a specific SSL certificate hash /// private const string OPTION_ACCEPT_SPECIFIED_CERTIFICATE = "accept-specified-ssl-hash"; // Global option /// /// The option used to accept any SSL certificate /// private const string OPTION_ACCEPT_ANY_CERTIFICATE = "accept-any-ssl-certificate"; // Global option /// /// 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" /// /// 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 test file name used to test access permissions /// private const string TEST_FILE_NAME = "duplicati-access-privileges-test.tmp"; /// /// The test file content used to test access permissions /// private const string TEST_FILE_CONTENT = "This file is used by Duplicati to test access permissions and can be safely deleted."; /// /// 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; /// /// 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 flag to indicate if all certificates should be accepted /// private readonly bool _accepAllCertificates; /// /// The valid certificate hashes /// private readonly string[] _validHashes; /// /// 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 => new List([ new CommandLineArgument("auth-password", CommandLineArgument.ArgumentType.Password, Strings.DescriptionAuthPasswordShort, Strings.DescriptionAuthPasswordLong), new CommandLineArgument("auth-username", CommandLineArgument.ArgumentType.String, Strings.DescriptionAuthUsernameShort, Strings.DescriptionAuthUsernameLong), 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), ]); /// /// Initialize a new instance. /// public FTP() { // TODO: Remove this constructor once static properties are introduced on IBackend _validHashes = null!; _ftpConfig = null!; _url = null!; _client = null!; } /// /// Initialize a new instance/ /// /// Configured url. /// Configured options. cannot be null. [SuppressMessage("ReSharper", "VirtualMemberCallInConstructor")] // The behavior of accessing the virtual properties is as expected public FTP(string url, Dictionary options) { _accepAllCertificates = CoreUtility.ParseBoolOption(options, OPTION_ACCEPT_ANY_CERTIFICATE); options.TryGetValue(OPTION_ACCEPT_SPECIFIED_CERTIFICATE, out var certHash); _validHashes = certHash?.Split([",", ";"], StringSplitOptions.RemoveEmptyEntries) ?? []; var u = new Utility.Uri(url); u.RequireHost(); if (!string.IsNullOrEmpty(u.Username)) { _userInfo = new NetworkCredential { UserName = u.Username }; if (!string.IsNullOrEmpty(u.Password)) _userInfo.Password = u.Password; else if (options.ContainsKey("auth-password")) _userInfo.Password = options["auth-password"]; } else { if (options.ContainsKey("auth-username")) { _userInfo = new NetworkCredential(); _userInfo.UserName = options["auth-username"]; if (options.ContainsKey("auth-password")) _userInfo.Password = options["auth-password"]; } } //Bugfix, see http://connect.microsoft.com/VisualStudio/feedback/details/695227/networkcredential-default-constructor-leaves-domain-null-leading-to-null-object-reference-exceptions-in-framework-code if (_userInfo != null) _userInfo.Domain = ""; 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); _ftpConfig = new FtpConfig { DataConnectionType = dataConnectionType, EncryptionMode = encryptionMode, SslProtocols = sslProtocols, LogToConsole = _logToConsole, ValidateAnyCertificate = _accepAllCertificates, Noop = true }; if (_logPrivateInfoToConsole) _ftpConfig.LogHost = _ftpConfig.LogPassword = _ftpConfig.LogUserName = true; } /// public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { FtpListItem[] items; try { var client = CreateClient(CancellationToken.None).Await(); var remotePath = PreparePathForClient(null); items = await client.GetListing(remotePath, FtpListOption.Modify | FtpListOption.Size).ConfigureAwait(false); } catch (Exception e) { if (TranslateException(null, ref e)) throw 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) { } var status = await client.UploadStream(input, 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) { if (TranslateException(remotename, ref e)) throw 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 client.OpenRead(clientRemoteName, token: cancelToken); await CoreUtility.CopyStreamAsync(inputStream, output, false, cancelToken).ConfigureAwait(false); } catch (Exception e) { if (TranslateException(remotename, ref e)) throw 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 client.DeleteFile(clientRemoteName, cancelToken); } catch (Exception e) { if (TranslateException(remotename, ref e)) throw e; throw; } } /// public virtual string Description => Strings.Description; /// public Task GetDNSNamesAsync(CancellationToken cancelToken) => Task.FromResult(new[] { _url.Host }); /// public async Task TestAsync(CancellationToken cancellationToken) { // Try to set the working directory to trigger a folder-not-found exception try { var client = await CreateClient(cancellationToken).ConfigureAwait(false); if (!_useCwdNames) { var folderpath = PreparePathForClient(null); await client.SetWorkingDirectory(folderpath, cancellationToken).ConfigureAwait(false); } } catch (Exception e) { if (TranslateException(null, ref e)) throw e; throw; } // Remove the file if it exists try { if (await ListAsync(cancellationToken).AnyAsync(entry => entry.Name == TEST_FILE_NAME).ConfigureAwait(false)) await DeleteAsync(TEST_FILE_NAME, cancellationToken).ConfigureAwait(false); } catch (Exception e) { if (TranslateException(TEST_FILE_NAME, ref e)) throw e; throw new UserInformationException(Strings.ErrorDeleteFile(TEST_FILE_NAME, e.Message), "TestPreparationError"); } // Test write permissions using (var testStream = new MemoryStream(Encoding.UTF8.GetBytes(TEST_FILE_CONTENT))) { try { await PutAsync(TEST_FILE_NAME, testStream, cancellationToken).ConfigureAwait(false); } catch (Exception e) { // Do not pass the filename here because a not-found should be treated as folder-not-found if (TranslateException(null, ref e)) throw e; throw new UserInformationException(Strings.ErrorWriteFile(TEST_FILE_NAME, e.Message), "TestWriteError"); } } // Test read permissions using (var testStream = new MemoryStream()) { try { await GetAsync(TEST_FILE_NAME, testStream, cancellationToken).ConfigureAwait(false); var readValue = Encoding.UTF8.GetString(testStream.ToArray()); if (readValue != TEST_FILE_CONTENT) throw new Exception("Test file corrupted."); } catch (Exception e) { if (TranslateException(TEST_FILE_NAME, ref e)) throw e; throw new UserInformationException(Strings.ErrorReadFile(TEST_FILE_NAME, e.Message), "TestReadError"); } } // Cleanup try { await DeleteAsync(TEST_FILE_NAME, cancellationToken).ConfigureAwait(false); } catch (Exception e) { if (TranslateException(TEST_FILE_NAME, ref e)) throw e; throw new UserInformationException(Strings.ErrorDeleteFile(TEST_FILE_NAME, e.Message), "TestCleanupError"); } } /// public async Task CreateFolderAsync(CancellationToken cancellationToken) { try { // Try to create the directory var client = await CreateClient(cancellationToken, false).ConfigureAwait(false); var clientPath = PreparePathForClient(null, false); 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, cancellationToken).ConfigureAwait(false); await client.CreateDirectory(folderName, true, cancellationToken).ConfigureAwait(false); // Reset the client and check that it works _client = null; client = await CreateClient(cancellationToken).ConfigureAwait(false); var cwd = await client.GetWorkingDirectory(cancellationToken).ConfigureAwait(false); if (!string.Equals(cwd?.TrimEnd('/'), clientPath, StringComparison.OrdinalIgnoreCase)) throw new UserInformationException(Strings.ErrorCreateFolder(clientPath, cwd), "CreateFolderError"); } else { await client.CreateDirectory(clientPath, true, cancellationToken); } } catch (Exception ex) { if (TranslateException(null, ref ex)) throw 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) { 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(cancellationToken).ConfigureAwait(false); // Set up for relative paths if (_relativePaths) { _initialCwd = await client.GetWorkingDirectory(cancellationToken).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, cancellationToken).ConfigureAwait(false); } _client = client; } 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 || _accepAllCertificates) { e.Accept = true; return; } e.Accept = false; try { var certHash = (_validHashes != null && _validHashes.Length > 0) ? e.Certificate?.GetCertHashString() : null; if (certHash != null && _validHashes != null && _validHashes.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. /// True if the exception was translated, otherwise false. private bool TranslateException(string? filename, ref 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); return true; } if (ex is SslCertificateValidator.InvalidCertificateException) return true; return false; } 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); } } } }