diff --git a/Duplicati/Library/Backend/FTP/AlternateFTPBackend.cs b/Duplicati/Library/Backend/FTP/AlternateFTPBackend.cs index a021dcf4f..bca7fc510 100644 --- a/Duplicati/Library/Backend/FTP/AlternateFTPBackend.cs +++ b/Duplicati/Library/Backend/FTP/AlternateFTPBackend.cs @@ -13,7 +13,7 @@ namespace Duplicati.Library.Backend; /// /// The functionality is identical to the FTP backend /// -public class AlternateFTPBackend: FTP +public class AlternateFTPBackend : FTP { /* These constants are overriden to provide transparent access to the alternate configuration keys @@ -24,9 +24,11 @@ public class AlternateFTPBackend: FTP protected override string CONFIG_KEY_FTP_UPLOAD_DELAY => "aftp-upload-delay"; protected override string CONFIG_KEY_FTP_LOGTOCONSOLE => "aftp-log-to-console"; protected override string CONFIG_KEY_FTP_LOGPRIVATEINFOTOCONSOLE => "aftp-log-privateinfo-to-console"; - + protected override string CONFIG_KEY_FTP_RELATIVE_PATH => "aftp-relative-path"; + protected override string CONFIG_KEY_FTP_USE_CWD_NAMES => "aftp-use-cwd-names"; + public override string Description => Strings.DescriptionAlternate; - + public override string DisplayName => Strings.DisplayNameAlternate; /* @@ -36,12 +38,12 @@ public class AlternateFTPBackend: FTP public AlternateFTPBackend() { - + } - public AlternateFTPBackend(string url, Dictionary options): base(url, options) + public AlternateFTPBackend(string url, Dictionary options) : base(url, options) { } - + /// /// Overriden so that for the alternate FTP backend, we need to support the alternate config keys starting with "aftp" /// @@ -49,7 +51,9 @@ public class AlternateFTPBackend: FTP 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("disable-upload-verify", CommandLineArgument.ArgumentType.Boolean, Strings.DescriptionDisableUploadVerifyShort, Strings.DescriptionDisableUploadVerifyLong), + new CommandLineArgument(CONFIG_KEY_DISABLE_UPLOAD_VERIFY, CommandLineArgument.ArgumentType.Boolean, Strings.DescriptionDisableUploadVerifyShort, Strings.DescriptionDisableUploadVerifyLong), + new CommandLineArgument(CONFIG_KEY_FTP_RELATIVE_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))), diff --git a/Duplicati/Library/Backend/FTP/FTPBackend.cs b/Duplicati/Library/Backend/FTP/FTPBackend.cs index c4bfb03da..8971f7a0d 100644 --- a/Duplicati/Library/Backend/FTP/FTPBackend.cs +++ b/Duplicati/Library/Backend/FTP/FTPBackend.cs @@ -1,4 +1,4 @@ -// Copyright (C) 2024, The Duplicati Team +// Copyright (C) 2024, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a @@ -19,6 +19,8 @@ // 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; @@ -41,7 +43,7 @@ using Uri = System.Uri; namespace Duplicati.Library.Backend { - + /// /// The unified FTP backend which uses the FluentFTP library. /// @@ -54,42 +56,154 @@ namespace Duplicati.Library.Backend /// public class FTP : IStreamingBackend { - private NetworkCredential _userInfo; + /// + /// 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; - private static readonly SslProtocols DEFAULT_SSL_PROTOCOLS = SslProtocols.None; // NOTE: None means "use system default" + /// + /// 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 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"; - private readonly string _url; + /// + /// 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 all certificates should be accepted + /// private readonly bool _accepAllCertificates; + /// + /// The valid certificate hashes + /// private readonly string[] _validHashes; /// @@ -102,14 +216,24 @@ namespace Duplicati.Library.Backend /// public virtual string ProtocolKey => "ftp"; - private AsyncFtpClient Client - { get; set; } + /// + /// 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("disable-upload-verify", CommandLineArgument.ArgumentType.Boolean, Strings.DescriptionDisableUploadVerifyShort, Strings.DescriptionDisableUploadVerifyLong), + 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))), @@ -126,7 +250,11 @@ namespace Duplicati.Library.Backend /// public FTP() { - + // TODO: Remove this constructor once static properties are introduced on IBackend + _validHashes = null!; + _ftpConfig = null!; + _url = null!; + _client = null!; } /// @@ -139,10 +267,9 @@ namespace Duplicati.Library.Backend { _accepAllCertificates = CoreUtility.ParseBoolOption(options, OPTION_ACCEPT_ANY_CERTIFICATE); - string certHash; - options.TryGetValue(OPTION_ACCEPT_SPECIFIED_CERTIFICATE, out certHash); + options.TryGetValue(OPTION_ACCEPT_SPECIFIED_CERTIFICATE, out var certHash); - _validHashes = certHash?.Split([",", ";"], StringSplitOptions.RemoveEmptyEntries); + _validHashes = certHash?.Split([",", ";"], StringSplitOptions.RemoveEmptyEntries) ?? []; var u = new Utility.Uri(url); u.RequireHost(); @@ -173,50 +300,36 @@ namespace Duplicati.Library.Backend if (_userInfo != null) _userInfo.Domain = ""; - _url = u.SetScheme("ftp").SetQuery(null).SetCredentials(null, null).ToString(); - _url = Util.AppendDirSeparator(_url, "/"); - _listVerify = !CoreUtility.ParseBoolOption(options, "disable-upload-verify"); + 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); - // Process the aftp-data-connection-type option - string dataConnectionTypeString; - FtpDataConnectionType dataConnectionType; - - if (!options.TryGetValue(CONFIG_KEY_FTP_DATA_CONNECTION_TYPE, out dataConnectionTypeString) || string.IsNullOrWhiteSpace(dataConnectionTypeString)) - dataConnectionTypeString = null; - - if (dataConnectionTypeString == null || !Enum.TryParse(dataConnectionTypeString, true, out dataConnectionType)) - dataConnectionType = DEFAULT_DATA_CONNECTION_TYPE; - - // Process the aftp-encryption-mode option - FtpEncryptionMode encryptionMode; - if (!options.TryGetValue(CONFIG_KEY_FTP_ENCRYPTION_MODE, out var encryptionModeString) || string.IsNullOrWhiteSpace(encryptionModeString)) - encryptionModeString = null; - - if (encryptionModeString == null || !Enum.TryParse(encryptionModeString, true, out encryptionMode)) - encryptionMode = DEFAULT_ENCRYPTION_MODE; - - // Process the aftp-ssl-protocols option - SslProtocols sslProtocols; - if (!options.TryGetValue(CONFIG_KEY_FTP_SSL_PROTOCOLS, out var sslProtocolsString) || string.IsNullOrWhiteSpace(sslProtocolsString)) - sslProtocolsString = null; - - if (sslProtocolsString == null || !Enum.TryParse(sslProtocolsString, true, out sslProtocols)) sslProtocols = DEFAULT_SSL_PROTOCOLS; + 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.ParseEnumOption(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)) + 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)) + 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)) @@ -225,246 +338,219 @@ namespace Duplicati.Library.Backend encryptionMode = FtpEncryptionMode.Auto; } } - + _logToConsole = CoreUtility.ParseBoolOption(options, CONFIG_KEY_FTP_LOGTOCONSOLE); _logPrivateInfoToConsole = CoreUtility.ParseBoolOption(options, CONFIG_KEY_FTP_LOGPRIVATEINFOTOCONSOLE); - + _ftpConfig = new FtpConfig { DataConnectionType = dataConnectionType, EncryptionMode = encryptionMode, SslProtocols = sslProtocols, LogToConsole = _logToConsole, + ValidateAnyCertificate = _accepAllCertificates }; if (_logPrivateInfoToConsole) _ftpConfig.LogHost = _ftpConfig.LogPassword = _ftpConfig.LogUserName = true; } + /// public IEnumerable List() { - return List(string.Empty); - } - - public IEnumerable List(string filename) - { - return List(filename, false); - } - - private IEnumerable List(string filename, bool stripFile) - { - var list = new List(); - string remotePath = filename; - - var ftpClient = CreateClient(); - - // Get the remote path - var url = new Uri(_url); - remotePath = "/" + GetUnescapedAbsolutePath(url); - - if (!string.IsNullOrEmpty(filename)) + try { - if (!stripFile) - { - // Append the filename - remotePath += filename; - } - else if (filename.Contains("/")) - { - remotePath += filename.Substring(0, filename.LastIndexOf("/", StringComparison.Ordinal)); - } - // else: stripping the filename in this case ignoring it - } + var list = new List(); + var client = CreateClient(CancellationToken.None).Await(); + var remotePath = PreparePathForClient(null); - foreach (FtpListItem item in ftpClient.GetListing(remotePath, FtpListOption.Modify | FtpListOption.Size).Await()) - { - switch (item.Type) + foreach (var item in client.GetListing(remotePath, FtpListOption.Modify | FtpListOption.Size).Await()) { - case FtpObjectType.Directory: - { + switch (item.Type) + { + case FtpObjectType.Directory: if (item.Name == "." || item.Name == "..") - { continue; - } list.Add(new FileEntry(item.Name, -1, new DateTime(), item.Modified) { IsFolder = true, }); - break; - } - case FtpObjectType.File: - { + + case FtpObjectType.File: list.Add(new FileEntry(item.Name, item.Size, new DateTime(), item.Modified)); - break; - } - case FtpObjectType.Link: - { - if (item.Name == "." || item.Name == "..") - { - continue; - } - if (item.LinkObject != null) + case FtpObjectType.Link: { - switch (item.LinkObject.Type) + if (item.Name == "." || item.Name == "..") + continue; + + if (item.LinkObject != null) { - case FtpObjectType.Directory: - { + switch (item.LinkObject.Type) + { + case FtpObjectType.Directory: if (item.Name == "." || item.Name == "..") - { continue; - } list.Add(new FileEntry(item.Name, -1, new DateTime(), item.Modified) { IsFolder = true, }); - break; - } - case FtpObjectType.File: - { + + case FtpObjectType.File: list.Add(new FileEntry(item.Name, item.Size, new DateTime(), item.Modified)); - break; - } + } } + break; } - break; - } + } } - } - return list; + return list; + } + catch (Exception e) + { + if (TranslateException(null, ref e)) + throw e; + + throw; + } } + /// public async Task PutAsync(string remotename, Stream input, CancellationToken cancelToken) { - string remotePath = remotename; - long streamLen; - - var ftpClient = CreateClient(); - try { - streamLen = input.Length; - } - catch (NotSupportedException) { streamLen = -1; } + var streamLen = -1L; + var client = await CreateClient(cancelToken).ConfigureAwait(false); + var clientRemoteName = PreparePathForClient(remotename); - // Get the remote path - remotePath = ""; + try { streamLen = input.Length; } + catch (NotSupportedException) { } - if (!string.IsNullOrEmpty(remotename)) - { - // Append the filename - remotePath += remotename; - } + 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"); - var status = await ftpClient.UploadStream(input, remotePath, createRemoteDir: false, token: cancelToken, progress: null).ConfigureAwait(false); - if (status != FtpStatus.Success) - { - throw new UserInformationException(string.Format(Strings.ErrorWriteFile, remotename), "AftpPutFailure"); - } + // Wait for the upload, if required + if (_uploadWaitTime.Ticks > 0) + Thread.Sleep(_uploadWaitTime); - // 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 ftpClient.GetFileSize(remotePath, -1, cancelToken); - if (streamLen != remoteSize) + if (_listVerify) { - throw new UserInformationException(Strings.ListVerifySizeFailure(remotename, remoteSize, streamLen), "AftpListVerifySizeFailure"); + // 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 FileStream fs = File.Open(localname, FileMode.Open, FileAccess.Read, FileShare.Read); + 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) { - var ftpClient = CreateClient(); - - // Get the remote path - var remotePath = ""; - - if (!string.IsNullOrEmpty(remotename)) - { - // Append the filename - remotePath += remotename; - } - - await using var inputStream = await ftpClient.OpenRead(remotePath, token: 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); } - finally + catch (Exception e) { - inputStream.Close(); + 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) { - var ftpClient = CreateClient(); - - // Get the remote path - var remotePath = ""; - - if (!string.IsNullOrEmpty(remotename)) + try { - // Append the filename - remotePath += remotename; + 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; - await ftpClient.DeleteFile(remotePath, cancelToken); + throw; + } } - /// - /// A localized description of the backend, for display in the usage information - /// + /// public virtual string Description => Strings.Description; - public Task GetDNSNamesAsync(CancellationToken cancelToken) => Task.FromResult(new[] { new Uri(_url).Host }); + /// + public Task GetDNSNamesAsync(CancellationToken cancelToken) => Task.FromResult(new[] { _url.Host }); - /// - /// Test FTP access permissions. - /// + /// public async Task TestAsync(CancellationToken cancellationToken) { - var list = List(); - - // Delete test file if exists - if (list.Any(entry => entry.Name == TEST_FILE_NAME)) + // Try to set the working directory to trigger a folder-not-found exception + try { - 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 + { + var list = List(); + if (list.Any(entry => entry.Name == TEST_FILE_NAME)) await DeleteAsync(TEST_FILE_NAME, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) - { - if (e.InnerException != null) { e = e.InnerException; } - throw new Exception(string.Format(Strings.ErrorDeleteFile, e.Message), e); - } + } + 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 @@ -476,8 +562,11 @@ namespace Duplicati.Library.Backend } catch (Exception e) { - if (e.InnerException != null) { e = e.InnerException; } - throw new Exception(string.Format(Strings.ErrorWriteFile, e.Message), 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"); } } @@ -493,8 +582,10 @@ namespace Duplicati.Library.Backend } catch (Exception e) { - if (e.InnerException != null) { e = e.InnerException; } - throw new Exception(string.Format(Strings.ErrorReadFile, e.Message), e); + if (TranslateException(TEST_FILE_NAME, ref e)) + throw e; + + throw new UserInformationException(Strings.ErrorReadFile(TEST_FILE_NAME, e.Message), "TestReadError"); } } @@ -505,82 +596,99 @@ namespace Duplicati.Library.Backend } catch (Exception e) { - if (e.InnerException != null) { e = e.InnerException; } - throw new Exception(string.Format(Strings.ErrorDeleteFile, e.Message), e); + if (TranslateException(TEST_FILE_NAME, ref e)) + throw e; + + throw new UserInformationException(Strings.ErrorDeleteFile(TEST_FILE_NAME, e.Message), "TestCleanupError"); } } - public Task CreateFolderAsync(CancellationToken cancellationToken) + /// + public async Task CreateFolderAsync(CancellationToken cancellationToken) { - var client = CreateClient(false); + 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); - var url = new Uri(_url); - - // Get the remote path - var remotePath = GetUnescapedAbsolutePath(url); - - // Try to create the directory - return client.CreateDirectory(remotePath, true, cancellationToken); + // 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() { - if (Client != null) - Client.Dispose(); - - Client = null; - _userInfo = null; + _client?.Dispose(); } - private AsyncFtpClient CreateClient(bool setWorkingDirectory = true) + /// + /// 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) { - var uri = new Uri(_url); - - if (Client == null) // Create connection if it doesn't exist yet + if (_client == null) { - var ftpClient = new AsyncFtpClient + var client = new AsyncFtpClient { - Host = uri.Host, - Port = uri.Port == -1 ? 21 : uri.Port, + Host = _url.Host, + Port = _url.Port == -1 ? 21 : _url.Port, Credentials = _userInfo, - Config = _ftpConfig, + Config = _ftpConfig }; - ftpClient.ValidateCertificate += HandleValidateCertificate; + client.ValidateCertificate += HandleValidateCertificate; - Client = ftpClient; - - } // else reuse existing connection - - if (setWorkingDirectory) - { - // Change working directory to the remote path - // Do this every time to prevent issues when FtpClient silently reconnects after failure. - var remotePath = GetUnescapedAbsolutePath(uri); - try + // Set up for relative paths + if (_relativePaths) { - Client.SetWorkingDirectory(remotePath).Await(); + _initialCwd = await client.GetWorkingDirectory(cancellationToken).ConfigureAwait(false); + _initialCwd = _initialCwd?.TrimEnd('/'); } - catch (FtpCommandException ex) - { - if (ex.CompletionCode == "550") - { - throw new FolderMissingException(Strings.MissingFolderError(remotePath, ex.Message), ex); - } - throw; + // 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; - } - - private string GetUnescapedAbsolutePath(Uri uri) - { - string absolutePath = Uri.UnescapeDataString(uri.AbsolutePath); - return absolutePath.EndsWith("/", StringComparison.Ordinal) ? absolutePath.Substring(0, absolutePath.Length - 1) : absolutePath; + 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) @@ -589,21 +697,79 @@ namespace Duplicati.Library.Backend return; } + e.Accept = false; try { - var certHash = (_validHashes != null && _validHashes.Length > 0) ? CoreUtility.ByteArrayAsHexString(e.Certificate.GetCertHash()) : null; - if (certHash != null) - { - if (_validHashes.Any(hash => !string.IsNullOrEmpty(hash) && certHash.Equals(hash, StringComparison.OrdinalIgnoreCase))) - { - e.Accept = true; - } - } + 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 { - e.Accept = false; } + + 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 + : 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 remotePath; + + if (path.StartsWith("/", StringComparison.Ordinal)) + return path; + + return 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; } } } diff --git a/Duplicati/Library/Backend/FTP/Strings.cs b/Duplicati/Library/Backend/FTP/Strings.cs index 95b36b82a..cd72bda65 100644 --- a/Duplicati/Library/Backend/FTP/Strings.cs +++ b/Duplicati/Library/Backend/FTP/Strings.cs @@ -52,9 +52,9 @@ namespace Duplicati.Library.Backend public static string DescriptionSslProtocolsShort { get { return LC.L(@"Configure the SSL policy to use when encryption is enabled"); } } public static string DescriptionUploadDelayLong { get { return LC.L(@"Some FTP servers need a small delay before reporting the correct file size. The required delay depends on network topology. If you experience errors related to the upload size not matching, try adding a few seconds delay."); } } public static string DescriptionUploadDelayShort { get { return LC.L(@"Add a delay after uploading a file"); } } - public static string ErrorDeleteFile { get { return LC.L(@"Error on deleting file: {0}"); } } - public static string ErrorReadFile { get { return LC.L(@"Error reading file: {0}"); } } - public static string ErrorWriteFile { get { return LC.L(@"Error writing file: {0}"); } } + public static string ErrorDeleteFile(string filename, string message) { return LC.L(@"Error on deleting file: {0}, error: {1}", filename, message); } + public static string ErrorReadFile(string filename, string message) { return LC.L(@"Error reading file: {0}, error: {1}", filename, message); } + public static string ErrorWriteFile(string filename, string message) { return LC.L(@"Error writing file: {0}, error: {1}", filename, message); } public static string DescriptionUseSSLLong { get { return LC.L(@"Use this option to communicate using Secure Socket Layer (SSL) over ftp (ftps)."); } } public static string DescriptionUseSSLShort { get { return LC.L(@"Instruct Duplicati to use an SSL (ftps) connection"); } } public static string DescriptionFTPActiveLong { get { return LC.L(@"Activate this option to make the FTP connection in active mode. Even if the option --{0} is also set, the connection will be made in active mode.", "ftp-passive"); } } @@ -64,6 +64,13 @@ namespace Duplicati.Library.Backend public static string FtpPassiveDeprecated { get { return LC.L(@"The option ftp-passive is deprecated, use ftp-data-connection-type instead."); } } public static string FtpActiveDeprecated { get { return LC.L(@"ftp-regular is deprecated, use ftp-data-connection-type instead."); } } public static string UseSslDeprecated { get { return LC.L(@"use-ssl is deprecated, use ftp-ssl-protocols instead."); } } - + public static string FileMissingError(string filename, string message) { return LC.L(@"The file {0} was not found. Message: {1}", filename, message); } + public static string DescriptionAbsolutePathShort { get { return LC.L(@"Treat the url path as absolute"); } } + public static string DescriptionAbsolutePathLong { get { return LC.L(@"Use this option to interpret the url path as an absolute path. This option only has an effect if the initial starting folder in the FTP server is not the (virtual) root folder. If not set, the path in the url is treated as relative to the initial login folder."); } } + public static string DescriptionRelativePathShort { get { return LC.L(@"Treat the url path as relative"); } } + public static string DescriptionRelativePathLong { get { return LC.L(@"Use this option to interpret the url path as a path that is relative to the initial login folder. This option only has an effect if the initial starting folder in the FTP server is not the (virtual) root folder. If not set, the path in the url is treated as absolute, ignoring the initial login folder."); } } + public static string DescriptionUseCwdNamesShort { get { return LC.L(@"Use CWD instead of absolute paths"); } } + public static string DescriptionUseCwdNamesLong { get { return LC.L(@"Use this option to start the connection with a CWD command instead of an absolute path. This can be useful if the FTP server does not support absolute paths."); } } + public static string ErrorCreateFolder(string targetFolderName, string resultingFolder) { return LC.L(@"Error creating folder {0}, gave folder: {1}", targetFolderName, resultingFolder); } } }