├── .gitignore ├── .travis.yml ├── .vscode └── settings.json ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── examples └── connecting.rs ├── src ├── data_stream.rs ├── ftp.rs ├── lib.rs ├── status.rs └── types.rs └── tests ├── Dockerfile ├── ftp-server.sh └── lib.rs /.gitignore: -------------------------------------------------------------------------------- 1 | .project 2 | /target 3 | /Cargo.lock 4 | *.swp 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | 3 | rust: 4 | # check it compiles on the latest stable compiler 5 | - stable 6 | 7 | before_install: 8 | | 9 | sudo apt-get update -qq && 10 | sudo apt-get install -yqq vsftpd && 11 | sudo useradd -s /bin/bash -d /home/ftp -m -c "Doe ftp user" -g ftp Doe && 12 | echo "Doe:mumble" | sudo chpasswd && 13 | echo "listen=yes 14 | anon_root=/home/ftp 15 | local_enable=yes 16 | local_umask=022 17 | pasv_enable=yes 18 | pasv_min_port=65000 19 | pasv_max_port=65010 20 | write_enable=yes 21 | log_ftp_protocol=yes" | sed -e "s/\s\+\(.*\)/\1/" | sudo tee /etc/vsftpd.conf && 22 | sudo service vsftpd restart && 23 | pip install "travis-cargo<0.2" --user && 24 | export PATH=$HOME/.local/bin:$PATH 25 | 26 | script: 27 | | 28 | travis-cargo build && 29 | travis-cargo test -- --features secure && 30 | travis-cargo --only stable doc 31 | 32 | after_success: 33 | # upload the documentation from the build with stable (automatically only actually 34 | # runs on the master branch, not individual PRs) 35 | - travis-cargo --only stable doc-upload 36 | 37 | env: 38 | global: 39 | secure: E/K+u8fhwLNKDvjG6kiuDumrXY/RMZOMa7SS88qhsPKStdHjNmaCwUFUe76RJDzMCqeN31u2mUwvMfMK3xDShABQjoD/tze/KbV5v6VTeL4vplHwZh6TzwaYKKBtNxL1q47A8FSTNK9PUbT+gEIAEY9Nadho7wKrYfT+CQxcb2A= 40 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.cargo.allFeatures": true 3 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 6.0.0 4 | - Update tokio-rustls to 0.23 5 | - If you don't have the `secure` feature enabled, this change doesn't affect you 6 | - If you do have it enabled, the docs should explain the changes from `DnsName` to `ServerName`, and the setup of `ClientConfig` 7 | ## 5.1.0 8 | - Add resume functionality 9 | - Added function to get server welcome message. 10 | - Use the peer address when the server responds with all zeroes like is the case with IPv6. 11 | - Added some small tests for types. 12 | - Make the test results clearer, using ? instead of asserting is_ok(). 13 | ## 5.0.0 14 | - Update to tokio 1.0. 15 | ## 4.0.4 16 | - Minor bug in FtpStream::get. 17 | ## 4.0.2 18 | - Make get_lines_from_stream work for unix newlines. 19 | - Add test for list returning unix newlines. 20 | ## 4.0.1 21 | - Drop data stream before waiting close code. 22 | ## 4.0.0 23 | - Initial release with 2018 edition and tokio support. 24 | 25 | 26 | ## For versions 3.0 and below, check the original sync fork: 27 | https://raw.githubusercontent.com/mattnenterprise/rust-ftp/master/CHANGELOG.md -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "async_ftp" 3 | version = "6.0.0" 4 | authors = ["Daniel García ", "Matt McCoy "] 5 | documentation = "https://docs.rs/async_ftp/" 6 | repository = "https://github.com/dani-garcia/rust_async_ftp" 7 | description = "FTP client for Rust" 8 | readme = "README.md" 9 | license = "Apache-2.0/MIT" 10 | edition = "2018" 11 | keywords = ["ftp"] 12 | categories = ["network-programming"] 13 | 14 | [features] 15 | # default = ["secure"] 16 | 17 | # Enable support of FTPS which requires openssl 18 | secure = ["tokio-rustls"] 19 | 20 | # Add debug output (to STDOUT) of commands sent to the server 21 | # and lines read from the server 22 | debug_print = [] 23 | 24 | [dependencies] 25 | lazy_static = "1.4.0" 26 | regex = "1.3.9" 27 | chrono = "0.4.11" 28 | 29 | tokio = { version = "1.0.1", features = ["net", "io-util"] } 30 | tokio-rustls = { version = "0.23.0", optional = true } 31 | pin-project = "1.0.0" 32 | 33 | [dev-dependencies] 34 | tokio = { version = "1.0.1", features = ["macros", "rt"] } 35 | tokio-util = { version = "0.6.0", features = ["io"] } 36 | tokio-stream = "0.1.0" 37 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 The rust-ftp Developers 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | rust-ftp 2 | ================ 3 | 4 | FTP client for Rust 5 | 6 | [![Number of Crate Downloads](https://img.shields.io/crates/d/async_ftp.svg)](https://crates.io/crates/async_ftp) 7 | [![Crate Version](https://img.shields.io/crates/v/async_ftp.svg)](https://crates.io/crates/async_ftp) 8 | [![Crate License](https://img.shields.io/crates/l/async_ftp.svg)](https://crates.io/crates/async_ftp) 9 | 10 | [Documentation](https://docs.rs/async_ftp/) 11 | 12 | ## Installation 13 | 14 | FTPS support is disabled by default. To enable it `secure` should be activated in `Cargo.toml`. 15 | ```toml 16 | [dependencies] 17 | async_ftp = { version = "", features = ["secure"] } 18 | ``` 19 | 20 | ## Usage 21 | ```rust 22 | use std::str; 23 | use std::io::Cursor; 24 | use async_ftp::FtpStream; 25 | 26 | async fn async_main() -> Result<(), Box> { 27 | // Create a connection to an FTP server and authenticate to it. 28 | let mut ftp_stream = FtpStream::connect("172.25.82.139:21").await?; 29 | let _ = ftp_stream.login("username", "password").await?; 30 | 31 | // Get the current directory that the client will be reading from and writing to. 32 | println!("Current directory: {}", ftp_stream.pwd().await?); 33 | 34 | // Change into a new directory, relative to the one we are currently in. 35 | let _ = ftp_stream.cwd("test_data").await?; 36 | 37 | // Retrieve (GET) a file from the FTP server in the current working directory. 38 | let remote_file = ftp_stream.simple_retr("ftpext-charter.txt").await?; 39 | println!("Read file with contents\n{}\n", str::from_utf8(&remote_file.into_inner()).await?); 40 | 41 | // Store (PUT) a file from the client to the current working directory of the server. 42 | let mut reader = Cursor::new("Hello from the Rust \"ftp\" crate!".as_bytes()); 43 | let _ = ftp_stream.put("greeting.txt", &mut reader).await?; 44 | println!("Successfully wrote greeting.txt"); 45 | 46 | // Terminate the connection to the server. 47 | let _ = ftp_stream.quit(); 48 | } 49 | 50 | fn main() -> Result<(), Box> { 51 | tokio::runtime::Builder::new() 52 | .threaded_scheduler() 53 | .enable_all() 54 | .build() 55 | .unwrap() 56 | .block_on(async_main()) 57 | } 58 | 59 | ``` 60 | 61 | ## License 62 | 63 | Licensed under either of 64 | 65 | * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) 66 | * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) 67 | 68 | at your option. 69 | 70 | ### Contribution 71 | 72 | Unless you explicitly state otherwise, any contribution intentionally 73 | submitted for inclusion in the work by you, as defined in the Apache-2.0 74 | license, shall be dual licensed as above, without any additional terms or 75 | conditions. 76 | 77 | ## Development environment 78 | 79 | All you need to develop rust-ftp and run the tests is Rust and Docker. 80 | The `tests` folder contains a `Dockerfile` that installs and configures 81 | the vsftpd server. 82 | 83 | To create the Docker image: 84 | 85 | ```bash 86 | docker build -t ftp-server tests 87 | ``` 88 | 89 | To start the FTP server that is tested against: 90 | 91 | ```bash 92 | tests/ftp-server.sh 93 | ``` 94 | 95 | This script runs the `ftp-server` image in detached mode and starts the `vsftpd` daemon. It binds ports 21 (FTP) as well as the range 65000-65010 for passive connections. 96 | 97 | Once you have an instance running, to run tests type: 98 | 99 | ```bash 100 | cargo test 101 | ``` 102 | 103 | The following commands can be useful: 104 | ```bash 105 | # List running containers of ftp-server image 106 | # (to include stopped containers use -a option) 107 | docker ps --filter ancestor=ftp-server 108 | 109 | # To stop and remove a container 110 | docker stop container_name 111 | docker rm container_name 112 | 113 | # To remove the image 114 | docker rmi ftp-server 115 | ``` 116 | -------------------------------------------------------------------------------- /examples/connecting.rs: -------------------------------------------------------------------------------- 1 | use async_ftp::{FtpError, FtpStream}; 2 | use std::io::Cursor; 3 | use std::str; 4 | 5 | async fn test_ftp(addr: &str, user: &str, pass: &str) -> Result<(), FtpError> { 6 | let mut ftp_stream = FtpStream::connect((addr, 21)).await?; 7 | ftp_stream.login(user, pass).await?; 8 | println!("current dir: {}", ftp_stream.pwd().await?); 9 | 10 | ftp_stream.cwd("test_data").await?; 11 | 12 | // An easy way to retrieve a file 13 | let cursor = ftp_stream.simple_retr("ftpext-charter.txt").await?; 14 | let vec = cursor.into_inner(); 15 | let text = str::from_utf8(&vec).unwrap(); 16 | println!("got data: {}", text); 17 | 18 | // Store a file 19 | let file_data = format!("Some awesome file data man!!"); 20 | let mut reader = Cursor::new(file_data.into_bytes()); 21 | ftp_stream.put("my_random_file.txt", &mut reader).await?; 22 | 23 | ftp_stream.quit().await 24 | } 25 | 26 | fn main() { 27 | let future = test_ftp("172.25.82.139", "anonymous", "rust-ftp@github.com"); 28 | 29 | tokio::runtime::Builder::new_current_thread() 30 | .enable_all() 31 | .build() 32 | .unwrap() 33 | .block_on(future) 34 | .unwrap(); 35 | 36 | println!("test successful") 37 | } 38 | -------------------------------------------------------------------------------- /src/data_stream.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use std::pin::Pin; 3 | use std::task::{Context, Poll}; 4 | use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; 5 | use tokio::net::TcpStream; 6 | #[cfg(feature = "secure")] 7 | use tokio_rustls::client::TlsStream; 8 | 9 | /// Data Stream used for communications 10 | #[pin_project::pin_project(project = DataStreamProj)] 11 | pub enum DataStream { 12 | Tcp(#[pin] TcpStream), 13 | #[cfg(feature = "secure")] 14 | Ssl(#[pin] TlsStream), 15 | } 16 | 17 | impl DataStream { 18 | /// Unwrap the stream into TcpStream. This method is only used in secure connection. 19 | pub fn into_tcp_stream(self) -> TcpStream { 20 | match self { 21 | DataStream::Tcp(stream) => stream, 22 | #[cfg(feature = "secure")] 23 | DataStream::Ssl(stream) => stream.into_inner().0, 24 | } 25 | } 26 | 27 | /// Test if the stream is secured 28 | pub fn is_ssl(&self) -> bool { 29 | match self { 30 | #[cfg(feature = "secure")] 31 | DataStream::Ssl(_) => true, 32 | _ => false, 33 | } 34 | } 35 | 36 | /// Returns a reference to the underlying TcpStream. 37 | pub fn get_ref(&self) -> &TcpStream { 38 | match self { 39 | DataStream::Tcp(ref stream) => stream, 40 | #[cfg(feature = "secure")] 41 | DataStream::Ssl(ref stream) => stream.get_ref().0, 42 | } 43 | } 44 | } 45 | 46 | impl AsyncRead for DataStream { 47 | fn poll_read( 48 | self: Pin<&mut Self>, 49 | cx: &mut Context<'_>, 50 | buf: &mut ReadBuf<'_>, 51 | ) -> Poll> { 52 | match self.project() { 53 | DataStreamProj::Tcp(stream) => stream.poll_read(cx, buf), 54 | #[cfg(feature = "secure")] 55 | DataStreamProj::Ssl(stream) => stream.poll_read(cx, buf), 56 | } 57 | } 58 | } 59 | 60 | impl AsyncWrite for DataStream { 61 | fn poll_write( 62 | self: Pin<&mut Self>, 63 | cx: &mut Context<'_>, 64 | buf: &[u8], 65 | ) -> Poll> { 66 | match self.project() { 67 | DataStreamProj::Tcp(stream) => stream.poll_write(cx, buf), 68 | #[cfg(feature = "secure")] 69 | DataStreamProj::Ssl(stream) => stream.poll_write(cx, buf), 70 | } 71 | } 72 | 73 | fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 74 | match self.project() { 75 | DataStreamProj::Tcp(stream) => stream.poll_flush(cx), 76 | #[cfg(feature = "secure")] 77 | DataStreamProj::Ssl(stream) => stream.poll_flush(cx), 78 | } 79 | } 80 | 81 | fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 82 | match self.project() { 83 | DataStreamProj::Tcp(stream) => stream.poll_shutdown(cx), 84 | #[cfg(feature = "secure")] 85 | DataStreamProj::Ssl(stream) => stream.poll_shutdown(cx), 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/ftp.rs: -------------------------------------------------------------------------------- 1 | //! FTP module. 2 | use std::borrow::Cow; 3 | use std::net::SocketAddr; 4 | use std::string::String; 5 | 6 | use chrono::offset::TimeZone; 7 | use chrono::{DateTime, Utc}; 8 | use regex::Regex; 9 | 10 | use tokio::io::{ 11 | copy, AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader, 12 | BufWriter, 13 | }; 14 | use tokio::net::{TcpStream, ToSocketAddrs}; 15 | 16 | #[cfg(feature = "secure")] 17 | use tokio_rustls::{rustls::ClientConfig, rustls::ServerName, TlsConnector}; 18 | 19 | use crate::data_stream::DataStream; 20 | use crate::status; 21 | use crate::types::{FileType, FtpError, Line, Result}; 22 | 23 | lazy_static::lazy_static! { 24 | // This regex extracts IP and Port details from PASV command response. 25 | // The regex looks for the pattern (h1,h2,h3,h4,p1,p2). 26 | static ref PORT_RE: Regex = Regex::new(r"\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)").unwrap(); 27 | 28 | // This regex extracts modification time from MDTM command response. 29 | static ref MDTM_RE: Regex = Regex::new(r"\b(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\b").unwrap(); 30 | 31 | // This regex extracts file size from SIZE command response. 32 | static ref SIZE_RE: Regex = Regex::new(r"\s+(\d+)\s*$").unwrap(); 33 | } 34 | 35 | /// Stream to interface with the FTP server. This interface is only for the command stream. 36 | pub struct FtpStream { 37 | reader: BufReader, 38 | #[cfg(feature = "secure")] 39 | ssl_cfg: Option<(ClientConfig, ServerName)>, 40 | welcome_msg: Option, 41 | } 42 | 43 | impl FtpStream { 44 | /// Creates an FTP Stream. 45 | pub async fn connect(addr: A) -> Result { 46 | let stream = TcpStream::connect(addr) 47 | .await 48 | .map_err(FtpError::ConnectionError)?; 49 | 50 | let mut ftp_stream = FtpStream { 51 | reader: BufReader::new(DataStream::Tcp(stream)), 52 | #[cfg(feature = "secure")] 53 | ssl_cfg: None, 54 | welcome_msg: None, 55 | }; 56 | let result = ftp_stream.read_response(status::READY).await?; 57 | ftp_stream.welcome_msg = Some(result.1); 58 | 59 | Ok(ftp_stream) 60 | } 61 | 62 | /// Switch to a secure mode if possible, using a provided SSL configuration. 63 | /// This method does nothing if the connect is already secured. 64 | /// 65 | /// ## Panics 66 | /// 67 | /// Panics if the plain TCP connection cannot be switched to TLS mode. 68 | /// 69 | /// ## Example 70 | /// 71 | /// ```rust,no_run 72 | /// use std::convert::TryFrom; 73 | /// use std::path::Path; 74 | /// use async_ftp::FtpStream; 75 | /// use tokio_rustls::rustls::{ClientConfig, RootCertStore, ServerName}; 76 | /// 77 | /// let mut root_store = RootCertStore::empty(); 78 | /// // root_store.add_pem_file(...); 79 | /// let conf = ClientConfig::builder().with_safe_defaults().with_root_certificates(root_store).with_no_client_auth(); 80 | /// let domain = ServerName::try_from("www.cert-domain.com").expect("invalid DNS name"); 81 | /// async { 82 | /// let mut ftp_stream = FtpStream::connect("172.25.82.139:21").await.unwrap(); 83 | /// let mut ftp_stream = ftp_stream.into_secure(conf, domain).await.unwrap(); 84 | /// }; 85 | /// ``` 86 | #[cfg(feature = "secure")] 87 | pub async fn into_secure(mut self, config: ClientConfig, domain: ServerName) -> Result { 88 | // Ask the server to start securing data. 89 | self.write_str("AUTH TLS\r\n").await?; 90 | self.read_response(status::AUTH_OK).await?; 91 | 92 | let connector: TlsConnector = std::sync::Arc::new(config.clone()).into(); 93 | let stream = connector 94 | .connect(domain.clone(), self.reader.into_inner().into_tcp_stream()) 95 | .await 96 | .map_err(|e| FtpError::SecureError(format!("{}", e)))?; 97 | 98 | let mut secured_ftp_tream = FtpStream { 99 | reader: BufReader::new(DataStream::Ssl(stream)), 100 | ssl_cfg: Some((config, domain)), 101 | welcome_msg: None, 102 | }; 103 | // Set protection buffer size 104 | secured_ftp_tream.write_str("PBSZ 0\r\n").await?; 105 | secured_ftp_tream.read_response(status::COMMAND_OK).await?; 106 | // Change the level of data protectio to Private 107 | secured_ftp_tream.write_str("PROT P\r\n").await?; 108 | secured_ftp_tream.read_response(status::COMMAND_OK).await?; 109 | Ok(secured_ftp_tream) 110 | } 111 | 112 | /// Switch to insecure mode. If the connection is already 113 | /// insecure does nothing. 114 | /// 115 | /// ## Example 116 | /// 117 | /// ```rust,no_run 118 | /// use std::convert::TryFrom; 119 | /// use std::path::Path; 120 | /// use async_ftp::FtpStream; 121 | /// use tokio_rustls::rustls::{ClientConfig, RootCertStore, ServerName}; 122 | /// 123 | /// let mut root_store = RootCertStore::empty(); 124 | /// // root_store.add_pem_file(...); 125 | /// let conf = ClientConfig::builder().with_safe_defaults().with_root_certificates(root_store).with_no_client_auth(); 126 | /// let domain = ServerName::try_from("www.cert-domain.com").expect("invalid DNS name"); 127 | /// async { 128 | /// let mut ftp_stream = FtpStream::connect("172.25.82.139:21").await.unwrap(); 129 | /// let mut ftp_stream = ftp_stream.into_secure(conf, domain).await.unwrap(); 130 | /// // Switch back to the insecure mode 131 | /// let mut ftp_stream = ftp_stream.into_insecure().await.unwrap(); 132 | /// // Do all public things 133 | /// let _ = ftp_stream.quit(); 134 | /// }; 135 | /// ``` 136 | #[cfg(feature = "secure")] 137 | pub async fn into_insecure(mut self) -> Result { 138 | // Ask the server to stop securing data 139 | self.write_str("CCC\r\n").await?; 140 | self.read_response(status::COMMAND_OK).await?; 141 | let plain_ftp_stream = FtpStream { 142 | reader: BufReader::new(DataStream::Tcp(self.reader.into_inner().into_tcp_stream())), 143 | ssl_cfg: None, 144 | welcome_msg: None, 145 | }; 146 | Ok(plain_ftp_stream) 147 | } 148 | 149 | /// Execute command which send data back in a separate stream 150 | async fn data_command(&mut self, cmd: &str) -> Result { 151 | let addr = self.pasv().await?; 152 | self.write_str(cmd).await?; 153 | 154 | let stream = TcpStream::connect(addr) 155 | .await 156 | .map_err(FtpError::ConnectionError)?; 157 | 158 | #[cfg(feature = "secure")] 159 | match &self.ssl_cfg { 160 | Some((config, domain)) => { 161 | let connector: TlsConnector = std::sync::Arc::new(config.clone()).into(); 162 | return connector 163 | .connect(domain.to_owned(), stream) 164 | .await 165 | .map(|stream| DataStream::Ssl(stream)) 166 | .map_err(|e| FtpError::SecureError(format!("{}", e))); 167 | } 168 | _ => {} 169 | }; 170 | 171 | Ok(DataStream::Tcp(stream)) 172 | } 173 | 174 | /// Returns a reference to the underlying TcpStream. 175 | /// 176 | /// Example: 177 | /// ```no_run 178 | /// use tokio::net::TcpStream; 179 | /// use std::time::Duration; 180 | /// use async_ftp::FtpStream; 181 | /// 182 | /// async { 183 | /// let stream = FtpStream::connect("172.25.82.139:21").await 184 | /// .expect("Couldn't connect to the server..."); 185 | /// let s: &TcpStream = stream.get_ref(); 186 | /// }; 187 | /// ``` 188 | pub fn get_ref(&self) -> &TcpStream { 189 | self.reader.get_ref().get_ref() 190 | } 191 | 192 | /// Get welcome message from the server on connect. 193 | pub fn get_welcome_msg(&self) -> Option<&str> { 194 | self.welcome_msg.as_deref() 195 | } 196 | 197 | /// Log in to the FTP server. 198 | pub async fn login(&mut self, user: &str, password: &str) -> Result<()> { 199 | self.write_str(format!("USER {}\r\n", user)).await?; 200 | let Line(code, _) = self 201 | .read_response_in(&[status::LOGGED_IN, status::NEED_PASSWORD]) 202 | .await?; 203 | if code == status::NEED_PASSWORD { 204 | self.write_str(format!("PASS {}\r\n", password)).await?; 205 | self.read_response(status::LOGGED_IN).await?; 206 | } 207 | Ok(()) 208 | } 209 | 210 | /// Change the current directory to the path specified. 211 | pub async fn cwd(&mut self, path: &str) -> Result<()> { 212 | self.write_str(format!("CWD {}\r\n", path)).await?; 213 | self.read_response(status::REQUESTED_FILE_ACTION_OK).await?; 214 | Ok(()) 215 | } 216 | 217 | /// Move the current directory to the parent directory. 218 | pub async fn cdup(&mut self) -> Result<()> { 219 | self.write_str("CDUP\r\n").await?; 220 | self.read_response_in(&[status::COMMAND_OK, status::REQUESTED_FILE_ACTION_OK]) 221 | .await?; 222 | Ok(()) 223 | } 224 | 225 | /// Gets the current directory 226 | pub async fn pwd(&mut self) -> Result { 227 | self.write_str("PWD\r\n").await?; 228 | self.read_response(status::PATH_CREATED) 229 | .await 230 | .and_then( 231 | |Line(_, content)| match (content.find('"'), content.rfind('"')) { 232 | (Some(begin), Some(end)) if begin < end => { 233 | Ok(content[begin + 1..end].to_string()) 234 | } 235 | _ => { 236 | let cause = format!("Invalid PWD Response: {}", content); 237 | Err(FtpError::InvalidResponse(cause)) 238 | } 239 | }, 240 | ) 241 | } 242 | 243 | /// This does nothing. This is usually just used to keep the connection open. 244 | pub async fn noop(&mut self) -> Result<()> { 245 | self.write_str("NOOP\r\n").await?; 246 | self.read_response(status::COMMAND_OK).await?; 247 | Ok(()) 248 | } 249 | 250 | /// This creates a new directory on the server. 251 | pub async fn mkdir(&mut self, pathname: &str) -> Result<()> { 252 | self.write_str(format!("MKD {}\r\n", pathname)).await?; 253 | self.read_response(status::PATH_CREATED).await?; 254 | Ok(()) 255 | } 256 | 257 | /// Runs the PASV command. 258 | async fn pasv(&mut self) -> Result { 259 | self.write_str("PASV\r\n").await?; 260 | // PASV response format : 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2). 261 | let Line(_, line) = self.read_response(status::PASSIVE_MODE).await?; 262 | PORT_RE 263 | .captures(&line) 264 | .ok_or_else(|| FtpError::InvalidResponse(format!("Invalid PASV response: {}", line))) 265 | .and_then(|caps| { 266 | // If the regex matches we can be sure groups contains numbers 267 | let (oct1, oct2, oct3, oct4) = ( 268 | caps[1].parse::().unwrap(), 269 | caps[2].parse::().unwrap(), 270 | caps[3].parse::().unwrap(), 271 | caps[4].parse::().unwrap(), 272 | ); 273 | let (msb, lsb) = ( 274 | caps[5].parse::().unwrap(), 275 | caps[6].parse::().unwrap(), 276 | ); 277 | let port = ((msb as u16) << 8) + lsb as u16; 278 | 279 | use std::net::{IpAddr, Ipv4Addr}; 280 | 281 | let ip = if (oct1, oct2, oct3, oct4) == (0, 0, 0, 0) { 282 | self.get_ref() 283 | .peer_addr() 284 | .map_err(FtpError::ConnectionError)? 285 | .ip() 286 | } else { 287 | IpAddr::V4(Ipv4Addr::new(oct1, oct2, oct3, oct4)) 288 | }; 289 | Ok(SocketAddr::new(ip, port)) 290 | }) 291 | } 292 | 293 | /// Sets the type of file to be transferred. That is the implementation 294 | /// of `TYPE` command. 295 | pub async fn transfer_type(&mut self, file_type: FileType) -> Result<()> { 296 | let type_command = format!("TYPE {}\r\n", file_type.to_string()); 297 | self.write_str(&type_command).await?; 298 | self.read_response(status::COMMAND_OK).await?; 299 | Ok(()) 300 | } 301 | 302 | /// Quits the current FTP session. 303 | pub async fn quit(&mut self) -> Result<()> { 304 | self.write_str("QUIT\r\n").await?; 305 | self.read_response(status::CLOSING).await?; 306 | Ok(()) 307 | } 308 | /// Sets the byte from which the transfer is to be restarted. 309 | pub async fn restart_from(&mut self, offset: u64) -> Result<()> { 310 | let rest_command = format!("REST {}\r\n", offset.to_string()); 311 | self.write_str(&rest_command).await?; 312 | self.read_response(status::REQUEST_FILE_PENDING) 313 | .await 314 | .map(|_| ()) 315 | } 316 | 317 | /// Retrieves the file name specified from the server. 318 | /// This method is a more complicated way to retrieve a file. 319 | /// The reader returned should be dropped. 320 | /// Also you will have to read the response to make sure it has the correct value. 321 | pub async fn get(&mut self, file_name: &str) -> Result> { 322 | let retr_command = format!("RETR {}\r\n", file_name); 323 | let data_stream = BufReader::new(self.data_command(&retr_command).await?); 324 | self.read_response_in(&[status::ABOUT_TO_SEND, status::ALREADY_OPEN]) 325 | .await?; 326 | Ok(data_stream) 327 | } 328 | 329 | /// Renames the file from_name to to_name 330 | pub async fn rename(&mut self, from_name: &str, to_name: &str) -> Result<()> { 331 | self.write_str(format!("RNFR {}\r\n", from_name)).await?; 332 | self.read_response(status::REQUEST_FILE_PENDING).await?; 333 | self.write_str(format!("RNTO {}\r\n", to_name)).await?; 334 | self.read_response(status::REQUESTED_FILE_ACTION_OK).await?; 335 | Ok(()) 336 | } 337 | 338 | /// The implementation of `RETR` command where `filename` is the name of the file 339 | /// to download from FTP and `reader` is the function which operates with the 340 | /// data stream opened. 341 | /// 342 | /// ``` 343 | /// use async_ftp::{FtpStream, DataStream, FtpError}; 344 | /// use tokio::io::{AsyncReadExt, BufReader}; 345 | /// use std::io::Cursor; 346 | /// async { 347 | /// let mut conn = FtpStream::connect("172.25.82.139:21").await.unwrap(); 348 | /// conn.login("Doe", "mumble").await.unwrap(); 349 | /// let mut reader = Cursor::new("hello, world!".as_bytes()); 350 | /// conn.put("retr.txt", &mut reader).await.unwrap(); 351 | /// 352 | /// async fn lambda(mut reader: BufReader) -> Result, FtpError> { 353 | /// let mut buffer = Vec::new(); 354 | /// reader 355 | /// .read_to_end(&mut buffer) 356 | /// .await 357 | /// .map_err(FtpError::ConnectionError)?; 358 | /// assert_eq!(buffer, "hello, world!".as_bytes()); 359 | /// Ok(buffer) 360 | /// }; 361 | /// 362 | /// assert!(conn.retr("retr.txt", lambda).await.is_ok()); 363 | /// assert!(conn.rm("retr.txt").await.is_ok()); 364 | /// }; 365 | /// ``` 366 | pub async fn retr(&mut self, filename: &str, reader: F) -> std::result::Result 367 | where 368 | F: Fn(BufReader) -> P, 369 | P: std::future::Future>, 370 | E: From, 371 | { 372 | let retr_command = format!("RETR {}\r\n", filename); 373 | 374 | let data_stream = BufReader::new(self.data_command(&retr_command).await?); 375 | self.read_response_in(&[status::ABOUT_TO_SEND, status::ALREADY_OPEN]) 376 | .await?; 377 | 378 | let res = reader(data_stream).await?; 379 | 380 | self.read_response_in(&[ 381 | status::CLOSING_DATA_CONNECTION, 382 | status::REQUESTED_FILE_ACTION_OK, 383 | ]) 384 | .await?; 385 | 386 | Ok(res) 387 | } 388 | 389 | /// Simple way to retr a file from the server. This stores the file in memory. 390 | /// 391 | /// ``` 392 | /// use async_ftp::{FtpStream, FtpError}; 393 | /// use std::io::Cursor; 394 | /// async { 395 | /// let mut conn = FtpStream::connect("172.25.82.139:21").await?; 396 | /// conn.login("Doe", "mumble").await?; 397 | /// let mut reader = Cursor::new("hello, world!".as_bytes()); 398 | /// conn.put("simple_retr.txt", &mut reader).await?; 399 | /// 400 | /// let cursor = conn.simple_retr("simple_retr.txt").await?; 401 | /// 402 | /// assert_eq!(cursor.into_inner(), "hello, world!".as_bytes()); 403 | /// assert!(conn.rm("simple_retr.txt").await.is_ok()); 404 | /// 405 | /// Ok::<(), FtpError>(()) 406 | /// }; 407 | /// ``` 408 | pub async fn simple_retr(&mut self, file_name: &str) -> Result>> { 409 | async fn lambda(mut reader: BufReader) -> Result> { 410 | let mut buffer = Vec::new(); 411 | reader 412 | .read_to_end(&mut buffer) 413 | .await 414 | .map_err(FtpError::ConnectionError)?; 415 | 416 | Ok(buffer) 417 | } 418 | 419 | let buffer = self.retr(file_name, lambda).await?; 420 | Ok(std::io::Cursor::new(buffer)) 421 | } 422 | 423 | /// Removes the remote pathname from the server. 424 | pub async fn rmdir(&mut self, pathname: &str) -> Result<()> { 425 | self.write_str(format!("RMD {}\r\n", pathname)).await?; 426 | self.read_response(status::REQUESTED_FILE_ACTION_OK).await?; 427 | Ok(()) 428 | } 429 | 430 | /// Remove the remote file from the server. 431 | pub async fn rm(&mut self, filename: &str) -> Result<()> { 432 | self.write_str(format!("DELE {}\r\n", filename)).await?; 433 | self.read_response(status::REQUESTED_FILE_ACTION_OK).await?; 434 | Ok(()) 435 | } 436 | 437 | async fn put_file(&mut self, filename: &str, r: &mut R) -> Result<()> { 438 | let stor_command = format!("STOR {}\r\n", filename); 439 | let mut data_stream = BufWriter::new(self.data_command(&stor_command).await?); 440 | self.read_response_in(&[status::ALREADY_OPEN, status::ABOUT_TO_SEND]) 441 | .await?; 442 | copy(r, &mut data_stream) 443 | .await 444 | .map_err(FtpError::ConnectionError)?; 445 | Ok(()) 446 | } 447 | 448 | /// This stores a file on the server. 449 | pub async fn put(&mut self, filename: &str, r: &mut R) -> Result<()> { 450 | self.put_file(filename, r).await?; 451 | self.read_response_in(&[ 452 | status::CLOSING_DATA_CONNECTION, 453 | status::REQUESTED_FILE_ACTION_OK, 454 | ]) 455 | .await?; 456 | Ok(()) 457 | } 458 | 459 | /// Execute a command which returns list of strings in a separate stream 460 | async fn list_command( 461 | &mut self, 462 | cmd: Cow<'_, str>, 463 | open_code: u32, 464 | close_code: &[u32], 465 | ) -> Result> { 466 | let data_stream = BufReader::new(self.data_command(&cmd).await?); 467 | self.read_response_in(&[open_code, status::ALREADY_OPEN]) 468 | .await?; 469 | let lines = Self::get_lines_from_stream(data_stream).await?; 470 | self.read_response_in(close_code).await?; 471 | Ok(lines) 472 | } 473 | 474 | /// Consume a stream and return a vector of lines 475 | async fn get_lines_from_stream(data_stream: R) -> Result> 476 | where 477 | R: AsyncBufRead + Unpin, 478 | { 479 | let mut lines: Vec = Vec::new(); 480 | 481 | let mut lines_stream = data_stream.lines(); 482 | loop { 483 | let line = lines_stream 484 | .next_line() 485 | .await 486 | .map_err(FtpError::ConnectionError)?; 487 | 488 | match line { 489 | Some(line) => { 490 | if line.is_empty() { 491 | continue; 492 | } 493 | lines.push(line); 494 | } 495 | None => break Ok(lines), 496 | } 497 | } 498 | } 499 | 500 | /// Execute `LIST` command which returns the detailed file listing in human readable format. 501 | /// If `pathname` is omited then the list of files in the current directory will be 502 | /// returned otherwise it will the list of files on `pathname`. 503 | pub async fn list(&mut self, pathname: Option<&str>) -> Result> { 504 | let command = pathname.map_or("LIST\r\n".into(), |path| { 505 | format!("LIST {}\r\n", path).into() 506 | }); 507 | 508 | self.list_command( 509 | command, 510 | status::ABOUT_TO_SEND, 511 | &[ 512 | status::CLOSING_DATA_CONNECTION, 513 | status::REQUESTED_FILE_ACTION_OK, 514 | ], 515 | ) 516 | .await 517 | } 518 | 519 | /// Execute `NLST` command which returns the list of file names only. 520 | /// If `pathname` is omited then the list of files in the current directory will be 521 | /// returned otherwise it will the list of files on `pathname`. 522 | pub async fn nlst(&mut self, pathname: Option<&str>) -> Result> { 523 | let command = pathname.map_or("NLST\r\n".into(), |path| { 524 | format!("NLST {}\r\n", path).into() 525 | }); 526 | 527 | self.list_command( 528 | command, 529 | status::ABOUT_TO_SEND, 530 | &[ 531 | status::CLOSING_DATA_CONNECTION, 532 | status::REQUESTED_FILE_ACTION_OK, 533 | ], 534 | ) 535 | .await 536 | } 537 | 538 | /// Retrieves the modification time of the file at `pathname` if it exists. 539 | /// In case the file does not exist `None` is returned. 540 | pub async fn mdtm(&mut self, pathname: &str) -> Result>> { 541 | self.write_str(format!("MDTM {}\r\n", pathname)).await?; 542 | let Line(_, content) = self.read_response(status::FILE).await?; 543 | 544 | match MDTM_RE.captures(&content) { 545 | Some(caps) => { 546 | let (year, month, day) = ( 547 | caps[1].parse::().unwrap(), 548 | caps[2].parse::().unwrap(), 549 | caps[3].parse::().unwrap(), 550 | ); 551 | let (hour, minute, second) = ( 552 | caps[4].parse::().unwrap(), 553 | caps[5].parse::().unwrap(), 554 | caps[6].parse::().unwrap(), 555 | ); 556 | Ok(Some( 557 | Utc.ymd(year, month, day).and_hms(hour, minute, second), 558 | )) 559 | } 560 | None => Ok(None), 561 | } 562 | } 563 | 564 | /// Retrieves the size of the file in bytes at `pathname` if it exists. 565 | /// In case the file does not exist `None` is returned. 566 | pub async fn size(&mut self, pathname: &str) -> Result> { 567 | self.write_str(format!("SIZE {}\r\n", pathname)).await?; 568 | let Line(_, content) = self.read_response(status::FILE).await?; 569 | 570 | match SIZE_RE.captures(&content) { 571 | Some(caps) => Ok(Some(caps[1].parse().unwrap())), 572 | None => Ok(None), 573 | } 574 | } 575 | 576 | async fn write_str>(&mut self, command: S) -> Result<()> { 577 | if cfg!(feature = "debug_print") { 578 | print!("CMD {}", command.as_ref()); 579 | } 580 | 581 | let stream = self.reader.get_mut(); 582 | stream 583 | .write_all(command.as_ref().as_bytes()) 584 | .await 585 | .map_err(FtpError::ConnectionError) 586 | } 587 | 588 | pub async fn read_response(&mut self, expected_code: u32) -> Result { 589 | self.read_response_in(&[expected_code]).await 590 | } 591 | 592 | /// Retrieve single line response 593 | pub async fn read_response_in(&mut self, expected_code: &[u32]) -> Result { 594 | let mut line = String::new(); 595 | self.reader 596 | .read_line(&mut line) 597 | .await 598 | .map_err(FtpError::ConnectionError)?; 599 | 600 | if cfg!(feature = "debug_print") { 601 | print!("FTP {}", line); 602 | } 603 | 604 | if line.len() < 5 { 605 | return Err(FtpError::InvalidResponse( 606 | "error: could not read reply code".to_owned(), 607 | )); 608 | } 609 | 610 | let code: u32 = line[0..3].parse().map_err(|err| { 611 | FtpError::InvalidResponse(format!("error: could not parse reply code: {}", err)) 612 | })?; 613 | 614 | // multiple line reply 615 | // loop while the line does not begin with the code and a space 616 | let expected = format!("{} ", &line[0..3]); 617 | while line.len() < 5 || line[0..4] != expected { 618 | line.clear(); 619 | if let Err(e) = self.reader.read_line(&mut line).await { 620 | return Err(FtpError::ConnectionError(e)); 621 | } 622 | 623 | if cfg!(feature = "debug_print") { 624 | print!("FTP {}", line); 625 | } 626 | } 627 | 628 | if expected_code.iter().any(|ec| code == *ec) { 629 | Ok(Line(code, line)) 630 | } else { 631 | Err(FtpError::InvalidResponse(format!( 632 | "Expected code {:?}, got response: {}", 633 | expected_code, line 634 | ))) 635 | } 636 | } 637 | } 638 | 639 | #[cfg(test)] 640 | mod tests { 641 | use super::FtpStream; 642 | use tokio_stream::once; 643 | use tokio_util::io::StreamReader; 644 | 645 | #[tokio::test] 646 | async fn list_command_dos_newlines() { 647 | let data_stream = StreamReader::new(once(Ok::<_, std::io::Error>( 648 | b"Hello\r\nWorld\r\n\r\nBe\r\nHappy\r\n" as &[u8], 649 | ))); 650 | 651 | assert_eq!( 652 | FtpStream::get_lines_from_stream(data_stream).await.unwrap(), 653 | ["Hello", "World", "Be", "Happy"] 654 | .iter() 655 | .map(<&str>::to_string) 656 | .collect::>() 657 | ); 658 | } 659 | 660 | #[tokio::test] 661 | async fn list_command_unix_newlines() { 662 | let data_stream = StreamReader::new(once(Ok::<_, std::io::Error>( 663 | b"Hello\nWorld\n\nBe\nHappy\n" as &[u8], 664 | ))); 665 | 666 | assert_eq!( 667 | FtpStream::get_lines_from_stream(data_stream).await.unwrap(), 668 | ["Hello", "World", "Be", "Happy"] 669 | .iter() 670 | .map(<&str>::to_string) 671 | .collect::>() 672 | ); 673 | } 674 | } 675 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! ftp is an FTP client written in Rust. 2 | //! 3 | //! ### Usage 4 | //! 5 | //! Here is a basic usage example: 6 | //! 7 | //! ```rust,no_run 8 | //! use async_ftp::FtpStream; 9 | //! async { 10 | //! let mut ftp_stream = FtpStream::connect("172.25.82.139:21").await.unwrap_or_else(|err| 11 | //! panic!("{}", err) 12 | //! ); 13 | //! let _ = ftp_stream.quit(); 14 | //! }; 15 | //! ``` 16 | //! 17 | //! ### FTPS 18 | //! 19 | //! The client supports FTPS on demand. To enable it the client should be 20 | //! compiled with feature `openssl` enabled what requires 21 | //! [openssl](https://crates.io/crates/openssl) dependency. 22 | //! 23 | //! The client uses explicit mode for connecting FTPS what means you should 24 | //! connect the server as usually and then switch to the secure mode (TLS is used). 25 | //! For better security it's the good practice to switch to the secure mode 26 | //! before authentication. 27 | //! 28 | //! ### FTPS Usage 29 | //! 30 | //! ```rust,no_run 31 | //! use std::convert::TryFrom; 32 | //! use std::path::Path; 33 | //! use async_ftp::FtpStream; 34 | //! use tokio_rustls::rustls::{ClientConfig, RootCertStore, ServerName}; 35 | //! 36 | //! async { 37 | //! let ftp_stream = FtpStream::connect("172.25.82.139:21").await.unwrap(); 38 | //! 39 | //! let mut root_store = RootCertStore::empty(); 40 | //! // root_store.add_pem_file(...); 41 | //! let conf = ClientConfig::builder().with_safe_defaults().with_root_certificates(root_store).with_no_client_auth(); 42 | //! let domain = ServerName::try_from("www.cert-domain.com").expect("invalid DNS name"); 43 | //! 44 | //! // Switch to the secure mode 45 | //! let mut ftp_stream = ftp_stream.into_secure(conf, domain).await.unwrap(); 46 | //! ftp_stream.login("anonymous", "anonymous").await.unwrap(); 47 | //! // Do other secret stuff 48 | //! // Switch back to the insecure mode (if required) 49 | //! let mut ftp_stream = ftp_stream.into_insecure().await.unwrap(); 50 | //! // Do all public stuff 51 | //! let _ = ftp_stream.quit().await; 52 | //! }; 53 | //! ``` 54 | //! 55 | 56 | mod data_stream; 57 | mod ftp; 58 | pub mod status; 59 | pub mod types; 60 | 61 | pub use self::data_stream::DataStream; 62 | pub use self::ftp::FtpStream; 63 | pub use self::types::FtpError; 64 | -------------------------------------------------------------------------------- /src/status.rs: -------------------------------------------------------------------------------- 1 | // 1xx: Positive Preliminary Reply 2 | pub const INITIATING: u32 = 100; 3 | pub const RESTART_MARKER: u32 = 110; 4 | pub const READY_MINUTE: u32 = 120; 5 | pub const ALREADY_OPEN: u32 = 125; 6 | pub const ABOUT_TO_SEND: u32 = 150; 7 | 8 | // 2xx: Positive Completion Reply 9 | pub const COMMAND_OK: u32 = 200; 10 | pub const COMMAND_NOT_IMPLEMENTED: u32 = 202; 11 | pub const SYSTEM: u32 = 211; 12 | pub const DIRECTORY: u32 = 212; 13 | pub const FILE: u32 = 213; 14 | pub const HELP: u32 = 214; 15 | pub const NAME: u32 = 215; 16 | pub const READY: u32 = 220; 17 | pub const CLOSING: u32 = 221; 18 | pub const DATA_CONNECTION_OPEN: u32 = 225; 19 | pub const CLOSING_DATA_CONNECTION: u32 = 226; 20 | pub const PASSIVE_MODE: u32 = 227; 21 | pub const LONG_PASSIVE_MODE: u32 = 228; 22 | pub const EXTENDED_PASSIVE_MODE: u32 = 229; 23 | pub const LOGGED_IN: u32 = 230; 24 | pub const LOGGED_OUT: u32 = 231; 25 | pub const LOGOUT_ACK: u32 = 232; 26 | pub const AUTH_OK: u32 = 234; 27 | pub const REQUESTED_FILE_ACTION_OK: u32 = 250; 28 | pub const PATH_CREATED: u32 = 257; 29 | 30 | // 3xx: Positive intermediate Reply 31 | pub const NEED_PASSWORD: u32 = 331; 32 | pub const LOGIN_NEED_ACCOUNT: u32 = 332; 33 | pub const REQUEST_FILE_PENDING: u32 = 350; 34 | 35 | // 4xx: Transient Negative Completion Reply 36 | pub const NOT_AVAILABLE: u32 = 421; 37 | pub const CANNOT_OPEN_DATA_CONNECTION: u32 = 425; 38 | pub const TRANSER_ABORTED: u32 = 426; 39 | pub const INVALID_CREDENTIALS: u32 = 430; 40 | pub const HOST_UNAVAILABLE: u32 = 434; 41 | pub const REQUEST_FILE_ACTION_IGNORED: u32 = 450; 42 | pub const ACTION_ABORTED: u32 = 451; 43 | pub const REQUESTED_ACTION_NOT_TAKEN: u32 = 452; 44 | 45 | // 5xx: Permanent Negative Completion Reply 46 | pub const BAD_COMMAND: u32 = 500; 47 | pub const BAD_ARGUMENTS: u32 = 501; 48 | pub const NOT_IMPLEMENTED: u32 = 502; 49 | pub const BAD_SEQUENCE: u32 = 503; 50 | pub const NOT_IMPLEMENTED_PARAMETER: u32 = 504; 51 | pub const NOT_LOGGED_IN: u32 = 530; 52 | pub const STORING_NEED_ACCOUNT: u32 = 532; 53 | pub const FILE_UNAVAILABLE: u32 = 550; 54 | pub const PAGE_TYPE_UNKNOWN: u32 = 551; 55 | pub const EXCEEDED_STORAGE: u32 = 552; 56 | pub const BAD_FILENAME: u32 = 553; 57 | -------------------------------------------------------------------------------- /src/types.rs: -------------------------------------------------------------------------------- 1 | //! The set of valid values for FTP commands 2 | 3 | use std::convert::From; 4 | use std::error::Error; 5 | use std::fmt; 6 | 7 | /// A shorthand for a Result whose error type is always an FtpError. 8 | pub type Result = ::std::result::Result; 9 | 10 | /// `FtpError` is a library-global error type to describe the different kinds of 11 | /// errors that might occur while using FTP. 12 | #[derive(Debug)] 13 | pub enum FtpError { 14 | ConnectionError(::std::io::Error), 15 | SecureError(String), 16 | InvalidResponse(String), 17 | InvalidAddress(::std::net::AddrParseError), 18 | } 19 | 20 | /// Text Format Control used in `TYPE` command 21 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 22 | pub enum FormatControl { 23 | /// Default text format control (is NonPrint) 24 | Default, 25 | /// Non-print (not destined for printing) 26 | NonPrint, 27 | /// Telnet format control (\, \, etc.) 28 | Telnet, 29 | /// ASA (Fortran) Carriage Control 30 | Asa, 31 | } 32 | 33 | /// File Type used in `TYPE` command 34 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 35 | pub enum FileType { 36 | /// ASCII text (the argument is the text format control) 37 | Ascii(FormatControl), 38 | /// EBCDIC text (the argument is the text format control) 39 | Ebcdic(FormatControl), 40 | /// Image, 41 | Image, 42 | /// Binary (the synonym to Image) 43 | Binary, 44 | /// Local format (the argument is the number of bits in one byte on local machine) 45 | Local(u8), 46 | } 47 | 48 | /// `Line` contains a command code and the contents of a line of text read from the network. 49 | pub struct Line(pub u32, pub String); 50 | 51 | impl ToString for FormatControl { 52 | fn to_string(&self) -> String { 53 | match self { 54 | &FormatControl::Default | &FormatControl::NonPrint => String::from("N"), 55 | &FormatControl::Telnet => String::from("T"), 56 | &FormatControl::Asa => String::from("C"), 57 | } 58 | } 59 | } 60 | 61 | impl ToString for FileType { 62 | fn to_string(&self) -> String { 63 | match self { 64 | &FileType::Ascii(ref fc) => format!("A {}", fc.to_string()), 65 | &FileType::Ebcdic(ref fc) => format!("E {}", fc.to_string()), 66 | &FileType::Image | &FileType::Binary => String::from("I"), 67 | &FileType::Local(ref bits) => format!("L {}", bits), 68 | } 69 | } 70 | } 71 | 72 | impl fmt::Display for FtpError { 73 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 74 | match *self { 75 | FtpError::ConnectionError(ref ioerr) => write!(f, "FTP ConnectionError: {}", ioerr), 76 | FtpError::SecureError(ref desc) => write!(f, "FTP SecureError: {}", desc.clone()), 77 | FtpError::InvalidResponse(ref desc) => { 78 | write!(f, "FTP InvalidResponse: {}", desc.clone()) 79 | } 80 | FtpError::InvalidAddress(ref perr) => write!(f, "FTP InvalidAddress: {}", perr), 81 | } 82 | } 83 | } 84 | 85 | impl Error for FtpError { 86 | fn source(&self) -> Option<&(dyn Error + 'static)> { 87 | match *self { 88 | FtpError::ConnectionError(ref ioerr) => Some(ioerr), 89 | FtpError::SecureError(_) => None, 90 | FtpError::InvalidResponse(_) => None, 91 | FtpError::InvalidAddress(ref perr) => Some(perr), 92 | } 93 | } 94 | } 95 | 96 | #[cfg(test)] 97 | mod tests { 98 | 99 | use super::*; 100 | 101 | #[test] 102 | fn format_control_str() { 103 | assert_eq!(FormatControl::Default.to_string(), "N"); 104 | assert_eq!(FormatControl::NonPrint.to_string(), "N"); 105 | assert_eq!(FormatControl::Telnet.to_string(), "T"); 106 | assert_eq!(FormatControl::Asa.to_string(), "C"); 107 | } 108 | 109 | #[test] 110 | fn file_type_str() { 111 | assert_eq!(FileType::Ascii(FormatControl::Default).to_string(), "A N"); 112 | assert_eq!(FileType::Ebcdic(FormatControl::Asa).to_string(), "E C"); 113 | assert_eq!(FileType::Image.to_string(), "I"); 114 | assert_eq!(FileType::Binary.to_string(), "I"); 115 | assert_eq!(FileType::Local(6).to_string(), "L 6"); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /tests/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:latest 2 | 3 | RUN apt update && apt install -y vsftpd 4 | 5 | RUN useradd --home-dir /home/ftp --create-home --groups ftp Doe 6 | RUN echo "Doe:mumble" | chpasswd 7 | 8 | RUN cp /etc/vsftpd.conf /etc/vsftpd.conf.orig 9 | RUN echo "write_enable=yes\nlog_ftp_protocol=yes" > /etc/vsftpd.conf 10 | RUN cat /etc/vsftpd.conf.orig >> /etc/vsftpd.conf 11 | 12 | RUN echo "/etc/init.d/vsftpd start" | tee -a /etc/bash.bashrc 13 | 14 | CMD ["/bin/bash"] 15 | -------------------------------------------------------------------------------- /tests/ftp-server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Build the docker image 4 | docker build -t ftp-server . 5 | 6 | # run the ftp server instance 7 | docker run --rm --name ftp-server -ti --net=host ftp-server 8 | -------------------------------------------------------------------------------- /tests/lib.rs: -------------------------------------------------------------------------------- 1 | use async_ftp::{FtpError, FtpStream}; 2 | #[cfg(test)] 3 | use std::io::Cursor; 4 | 5 | #[test] 6 | fn test_ftp() { 7 | let future = async { 8 | let mut ftp_stream = FtpStream::connect("192.168.1.60:21").await?; 9 | let _ = ftp_stream.login("Doe", "mumble").await?; 10 | 11 | ftp_stream.mkdir("test_dir").await?; 12 | ftp_stream.cwd("test_dir").await?; 13 | assert!(ftp_stream.pwd().await?.ends_with("/test_dir")); 14 | 15 | // store a file 16 | let file_data = "test data\n"; 17 | let mut reader = Cursor::new(file_data.as_bytes()); 18 | ftp_stream.put("test_file.txt", &mut reader).await?; 19 | 20 | // retrieve file 21 | ftp_stream 22 | .simple_retr("test_file.txt") 23 | .await 24 | .map(|bytes| assert_eq!(bytes.into_inner(), file_data.as_bytes()))?; 25 | 26 | // remove file 27 | ftp_stream.rm("test_file.txt").await?; 28 | 29 | // cleanup: go up, remove folder, and quit 30 | ftp_stream.cdup().await?; 31 | 32 | ftp_stream.rmdir("test_dir").await?; 33 | ftp_stream.quit().await?; 34 | 35 | Ok(()) 36 | }; 37 | 38 | let result: Result<(), FtpError> = tokio::runtime::Builder::new_current_thread() 39 | .enable_all() 40 | .build() 41 | .unwrap() 42 | .block_on(future); 43 | 44 | result.unwrap(); 45 | } 46 | --------------------------------------------------------------------------------