├── .gitignore ├── .rspec ├── .travis.yml ├── .yardopts ├── Changelog.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.md ├── README.md ├── Rakefile ├── bin └── ftpdrb ├── doc ├── benchmarks.md ├── references.md └── rfc-compliance.md ├── examples ├── example.rb ├── example_spec.rb ├── hello_world.rb └── write_only.rb ├── features ├── example │ ├── eplf.feature │ ├── example.feature │ ├── read_only.feature │ └── step_definitions │ │ └── example_server.rb ├── ftp_server │ ├── abort.feature │ ├── allo.feature │ ├── append.feature │ ├── cdup.feature │ ├── command_errors.feature │ ├── concurrent_sessions.feature │ ├── delay_after_failed_login.feature │ ├── delete.feature │ ├── directory_navigation.feature │ ├── disconnect_after_failed_logins.feature │ ├── eprt.feature │ ├── epsv.feature │ ├── features.feature │ ├── file_structure.feature │ ├── get.feature │ ├── get_ipv6.feature │ ├── get_tls.feature │ ├── help.feature │ ├── implicit_tls.feature │ ├── invertability.feature │ ├── list.feature │ ├── list_tls.feature │ ├── logging.feature │ ├── login_auth_level_account.feature │ ├── login_auth_level_password.feature │ ├── login_auth_level_user.feature │ ├── max_connections.feature │ ├── mdtm.feature │ ├── mkdir.feature │ ├── mode.feature │ ├── name_list.feature │ ├── name_list_tls.feature │ ├── noop.feature │ ├── options.feature │ ├── pasv.feature │ ├── port.feature │ ├── put.feature │ ├── put_tls.feature │ ├── put_unique.feature │ ├── quit.feature │ ├── reinitialize.feature │ ├── rename.feature │ ├── rmdir.feature │ ├── site.feature │ ├── size.feature │ ├── status.feature │ ├── step_definitions │ │ ├── logging.rb │ │ └── test_server.rb │ ├── structure_mount.feature │ ├── syntax_errors.feature │ ├── syst.feature │ ├── timeout.feature │ └── type.feature ├── step_definitions │ ├── append.rb │ ├── client.rb │ ├── client_and_server_files.rb │ ├── client_files.rb │ ├── command.rb │ ├── connect.rb │ ├── delete.rb │ ├── directory_navigation.rb │ ├── error_replies.rb │ ├── features.rb │ ├── file_structure.rb │ ├── generic_send.rb │ ├── get.rb │ ├── help.rb │ ├── invalid_commands.rb │ ├── ipv6.rb │ ├── line_endings.rb │ ├── list.rb │ ├── login.rb │ ├── mkdir.rb │ ├── mode.rb │ ├── mtime.rb │ ├── noop.rb │ ├── options.rb │ ├── passive.rb │ ├── pending.rb │ ├── port.rb │ ├── put.rb │ ├── quit.rb │ ├── rename.rb │ ├── rmdir.rb │ ├── server_files.rb │ ├── server_title.rb │ ├── size.rb │ ├── status.rb │ ├── success_replies.rb │ ├── system.rb │ ├── timing.rb │ └── type.rb └── support │ ├── env.rb │ ├── example_server.rb │ ├── file_templates │ ├── ascii_unix │ ├── ascii_windows │ └── binary │ ├── test_client.rb │ ├── test_file_templates.rb │ ├── test_server.rb │ └── test_server_files.rb ├── ftpd.gemspec ├── insecure-test-cert.pem ├── lib ├── ftpd.rb └── ftpd │ ├── auth_levels.rb │ ├── cmd_abor.rb │ ├── cmd_allo.rb │ ├── cmd_appe.rb │ ├── cmd_auth.rb │ ├── cmd_cdup.rb │ ├── cmd_cwd.rb │ ├── cmd_dele.rb │ ├── cmd_eprt.rb │ ├── cmd_epsv.rb │ ├── cmd_feat.rb │ ├── cmd_help.rb │ ├── cmd_list.rb │ ├── cmd_login.rb │ ├── cmd_mdtm.rb │ ├── cmd_mkd.rb │ ├── cmd_mode.rb │ ├── cmd_nlst.rb │ ├── cmd_noop.rb │ ├── cmd_opts.rb │ ├── cmd_pasv.rb │ ├── cmd_pbsz.rb │ ├── cmd_port.rb │ ├── cmd_prot.rb │ ├── cmd_pwd.rb │ ├── cmd_quit.rb │ ├── cmd_rein.rb │ ├── cmd_rename.rb │ ├── cmd_rest.rb │ ├── cmd_retr.rb │ ├── cmd_rmd.rb │ ├── cmd_site.rb │ ├── cmd_size.rb │ ├── cmd_smnt.rb │ ├── cmd_stat.rb │ ├── cmd_stor.rb │ ├── cmd_stou.rb │ ├── cmd_stru.rb │ ├── cmd_syst.rb │ ├── cmd_type.rb │ ├── command_handler.rb │ ├── command_handler_factory.rb │ ├── command_handlers.rb │ ├── command_loop.rb │ ├── command_sequence_checker.rb │ ├── config.rb │ ├── connection_throttle.rb │ ├── connection_tracker.rb │ ├── data_connection_helper.rb │ ├── data_server_factory.rb │ ├── data_server_factory │ ├── random_ephemeral_port.rb │ └── specific_port_range.rb │ ├── disk_file_system.rb │ ├── error.rb │ ├── exception_translator.rb │ ├── exceptions.rb │ ├── file_info.rb │ ├── file_system_helper.rb │ ├── ftp_server.rb │ ├── gets_peer_address.rb │ ├── insecure_certificate.rb │ ├── list_format │ ├── eplf.rb │ └── ls.rb │ ├── list_path.rb │ ├── null_logger.rb │ ├── protocols.rb │ ├── release.rb │ ├── server.rb │ ├── session.rb │ ├── session_config.rb │ ├── stream.rb │ ├── telnet.rb │ ├── temp_dir.rb │ ├── tls_server.rb │ └── translate_exceptions.rb ├── rake_tasks ├── cucumber.rake ├── default.rake ├── spec.rake ├── test.rake └── yard.rake ├── spec ├── command_sequence_checker_spec.rb ├── connection_throttle_spec.rb ├── connection_tracker_spec.rb ├── data_server_factory_spec.rb ├── disk_file_system_spec.rb ├── exception_translator_spec.rb ├── file_info_spec.rb ├── ftp_server_error_spec.rb ├── list_format │ ├── eplf_spec.rb │ └── ls_spec.rb ├── list_path_spec.rb ├── null_logger_spec.rb ├── protocols_spec.rb ├── server_spec.rb ├── spec_helper.rb ├── telnet_spec.rb └── translate_exceptions_spec.rb └── testlib └── network.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | .DS_Store 3 | *# 4 | pkg/ 5 | doc-api/ 6 | .yardoc/ 7 | .bundle/ 8 | spec/examples.txt 9 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.9.3 4 | - 2.0 5 | - 2.1 6 | - 2.2 7 | - 2.3 8 | - 2.4 9 | sudo: false 10 | script: bundle exec rake test 11 | dist: precise 12 | install: "bundle install --jobs=3 --retry=3" 13 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | -o doc-api 2 | --exclude '[~#]$' 3 | --readme README.md 4 | --files Changelog.md 5 | --files doc/*.md 6 | lib/**/*.rb 7 | examples/example.rb 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | ftpd (2.1.0) 5 | memoizer (~> 1.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | builder (3.2.3) 11 | cucumber (2.4.0) 12 | builder (>= 2.1.2) 13 | cucumber-core (~> 1.5.0) 14 | cucumber-wire (~> 0.0.1) 15 | diff-lcs (>= 1.1.3) 16 | gherkin (~> 4.0) 17 | multi_json (>= 1.7.5, < 2.0) 18 | multi_test (>= 0.1.2) 19 | cucumber-core (1.5.0) 20 | gherkin (~> 4.0) 21 | cucumber-wire (0.0.1) 22 | diff-lcs (1.3) 23 | double-bag-ftps (0.1.4) 24 | gherkin (4.1.3) 25 | memoizer (1.0.3) 26 | multi_json (1.12.1) 27 | multi_test (0.1.2) 28 | rake (11.3.0) 29 | redcarpet (3.4.0) 30 | rspec (3.6.0) 31 | rspec-core (~> 3.6.0) 32 | rspec-expectations (~> 3.6.0) 33 | rspec-mocks (~> 3.6.0) 34 | rspec-core (3.6.0) 35 | rspec-support (~> 3.6.0) 36 | rspec-expectations (3.6.0) 37 | diff-lcs (>= 1.2.0, < 2.0) 38 | rspec-support (~> 3.6.0) 39 | rspec-its (1.2.0) 40 | rspec-core (>= 3.0.0) 41 | rspec-expectations (>= 3.0.0) 42 | rspec-mocks (3.6.0) 43 | diff-lcs (>= 1.2.0, < 2.0) 44 | rspec-support (~> 3.6.0) 45 | rspec-support (3.6.0) 46 | timecop (0.9.1) 47 | yard (0.8.7.6) 48 | 49 | PLATFORMS 50 | ruby 51 | 52 | DEPENDENCIES 53 | cucumber (~> 2.0) 54 | double-bag-ftps (~> 0.1, >= 0.1.4) 55 | ftpd! 56 | rake (~> 11.1) 57 | redcarpet (~> 3.1) 58 | rspec (~> 3.1) 59 | rspec-its (~> 1.0) 60 | timecop (~> 0.7) 61 | yard (~> 0.8.7) 62 | 63 | BUNDLED WITH 64 | 1.15.3 65 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2012 Wayne Conrad 2 | 3 | This software is distributed under the [MIT License](http://opensource.org/licenses/MIT): 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'rubygems' 3 | require 'bundler' 4 | 5 | begin 6 | Bundler.setup(:default, :development) 7 | rescue Bundler::BundlerError => e 8 | $stderr.puts e.message 9 | $stderr.puts 'Run `bundle install` to install missing gems' 10 | exit e.status_code 11 | end 12 | 13 | $:.unshift(File.dirname(__FILE__) + '/lib') 14 | Dir['rake_tasks/**/*.rake'].sort.each { |path| load path } 15 | -------------------------------------------------------------------------------- /doc/references.md: -------------------------------------------------------------------------------- 1 | # REFERENCES 2 | 3 | ## RFCs 4 | 5 | _This list of references comes from the README of the em-ftpd gem, 6 | which is licensed under the same MIT license as this gem, and is 7 | Copyright (c) 2008 James Healy_ 8 | 9 | There are a range of RFCs that together specify the FTP protocol. In 10 | chronological order, the more useful ones are: 11 | 12 | * [RFC-854](http://tools.ietf.org/rfc/rfc854.txt) - Telnet Protocol 13 | Specification 14 | 15 | * [RFC-959](http://tools.ietf.org/rfc/rfc959.txt) - File Transfer 16 | Protocol 17 | 18 | * [RFC-1123](http://tools.ietf.org/rfc/rfc1123.txt) - Requirements for 19 | Internet Hosts 20 | 21 | * [RFC-1143](http://tools.ietf.org/rfc/rfc1143.txt) - The Q Method of 22 | Implementing TELNET Option Negotation 23 | 24 | * [RFC-2228](http://tools.ietf.org/rfc/rfc2228.txt) - FTP Security 25 | Extensions 26 | 27 | * [RFC-2389](http://tools.ietf.org/rfc/rfc2389.txt) - Feature 28 | negotiation mechanism for the File Transfer Protocol 29 | 30 | * [RFC-2428](http://tools.ietf.org/rfc/rfc2428.txt) - FTP Extensions 31 | for IPv6 and NATs 32 | 33 | * [RFC-2577](http://tools.ietf.org/rfc/rfc2577.txt) - FTP Security 34 | Considerations 35 | 36 | * [RFC-2640](http://tools.ietf.org/rfc/rfc2640.txt) - 37 | Internationalization of the File Transfer Protocol 38 | 39 | * [RFC-3659](http://tools.ietf.org/rfc/rfc3659.txt) - Extensions to 40 | FTP 41 | 42 | * [RFC-4217](http://tools.ietf.org/rfc/rfc4217.txt) - 43 | Securing FTP with TLS 44 | 45 | For an english summary that's somewhat more legible than the RFCs, and 46 | provides some commentary on what features are actually useful or 47 | relevant 24 years after RFC959 was published: 48 | 49 | * 50 | 51 | For a history lesson, check out Appendix III of RCF959. It lists the 52 | preceding (obsolete) RFC documents that relate to file transfers, 53 | including the ye old RFC114 from 1971, "A File Transfer Protocol" 54 | 55 | There is a [public test server](http://secureftp-test.com) which is 56 | very handy for checking out clients, and seeing how at least one 57 | server behaves. 58 | 59 | ## How to reliably close a socket (and not lose data) 60 | 61 | [Why is my TCP not reliable](http://ia600609.us.archive.org/22/items/TheUltimateSo_lingerPageOrWhyIsMyTcpNotReliable/the-ultimate-so_linger-page-or-why-is-my-tcp-not-reliable.html) by Bert Hubert 62 | 63 | ## LIST output format 64 | 65 | * [GNU docs for ls](http://www.gnu.org/software/coreutils/manual/html_node/What-information-is-listed.html#What-information-is-listed) 66 | * [Easily Parsed LIST format (EPLF)](http://cr.yp.to/ftp/list/eplf.html) 67 | -------------------------------------------------------------------------------- /examples/example_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | # Authour: Michael de Silva 4 | # CEO @ http://omakaselabs.com / Mentor @ http://railsphd.com 5 | # https://twitter.com/bsodmike / https://github.com/bsodmike 6 | 7 | # This is an example for using Ftpd as a means for spec driving 8 | # interaction with a 'dummy' ftp server via RSpec. In this example we 9 | # assume the client is implemented via `Fetcher::FTPFetcher`. 10 | 11 | unless $:.include?(File.dirname(__FILE__) + '/../lib') 12 | $:.unshift(File.dirname(__FILE__) + '/../lib') 13 | end 14 | 15 | require 'net/ftp' 16 | require 'ftpd' 17 | require 'tmpdir' 18 | 19 | # This is an example client spec driven via the use of Ftpd within the 20 | # specs. The specs spawn a 'dummy' Ftpd server and ensure this client 21 | # operates as expected. 22 | 23 | module Fetcher 24 | 25 | # This is the code under test, a simple fetcher that logs into an 26 | # FTP site, changes to a directory, and gets a list of files. 27 | 28 | class FTPFetcher 29 | 30 | # @param host [String] ftp host to connect to. 31 | # @param user [String] username. 32 | # @param pwd [String] password. 33 | # @param dir [String] remote directory to change to. 34 | 35 | def initialize(host, user, pwd, dir) 36 | @host = host 37 | @user = user 38 | @pwd = pwd 39 | @dir = dir 40 | @ftp = Net::FTP.new 41 | end 42 | 43 | # @param port [Fixnum] port to connect to, 21 by default. 44 | # @return [Array] list of files in the current directory. 45 | 46 | def connect_and_list(port = 21) 47 | @ftp.debug_mode = true if ENV['DEBUG'] == "true" 48 | @ftp.passive = true 49 | @ftp.connect @host, port 50 | @ftp.login @user, @pwd 51 | @ftp.chdir @dir 52 | @ftp.nlst 53 | end 54 | 55 | end 56 | end 57 | 58 | describe Fetcher::FTPFetcher do 59 | 60 | # This `Driver` tells Ftpd how to authenticate and how to interact 61 | # with the file system. In this example, the file system is 62 | # read-only and contains a single file. 63 | 64 | class Driver 65 | def initialize 66 | @data_dir = Dir.mktmpdir 67 | at_exit {FileUtils.rm_rf(@data_dir)} 68 | FileUtils.touch File.expand_path('report.txt', @data_dir) 69 | end 70 | def authenticate(user, pwd); true; end 71 | def file_system(user); Ftpd::ReadOnlyDiskFileSystem.new(@data_dir); end 72 | end 73 | 74 | let(:server) do 75 | server = Ftpd::FtpServer.new(Driver.new) 76 | server.interface = "127.0.0.1" 77 | server.start 78 | server 79 | end 80 | 81 | let(:subject) do 82 | Fetcher::FTPFetcher.new('127.0.0.1', 'user', 'password', '/') 83 | end 84 | 85 | describe "#connect_and_list" do 86 | 87 | it "should connect to the FTP server and find 'report.txt' in the Array returned" do 88 | result = subject.connect_and_list(server.bound_port) 89 | expect(result).to include('report.txt') 90 | end 91 | 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /examples/hello_world.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | unless $:.include?(File.dirname(__FILE__) + '/../lib') 4 | $:.unshift(File.dirname(__FILE__) + '/../lib') 5 | end 6 | 7 | require 'ftpd' 8 | require 'tmpdir' 9 | 10 | class Driver 11 | 12 | def initialize(temp_dir) 13 | @temp_dir = temp_dir 14 | end 15 | 16 | def authenticate(user, password) 17 | true 18 | end 19 | 20 | def file_system(user) 21 | Ftpd::DiskFileSystem.new(@temp_dir) 22 | end 23 | 24 | end 25 | 26 | Dir.mktmpdir do |temp_dir| 27 | driver = Driver.new(temp_dir) 28 | server = Ftpd::FtpServer.new(driver) 29 | server.start 30 | puts "Server listening on port #{server.bound_port}" 31 | gets 32 | end 33 | -------------------------------------------------------------------------------- /examples/write_only.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # This example shows how to create a "write-only" file system for FTPD. 4 | 5 | unless $:.include?(File.dirname(__FILE__) + "/../lib") 6 | $:.unshift(File.dirname(__FILE__) + "/../lib") 7 | end 8 | 9 | require "ftpd" 10 | require "tmpdir" 11 | 12 | class FileSystem 13 | 14 | def initialize(user) 15 | @user = user 16 | end 17 | 18 | def accessible?(ftp_path) 19 | true 20 | end 21 | 22 | def exists?(ftp_path) 23 | true 24 | end 25 | 26 | def directory?(ftp_path) 27 | false 28 | end 29 | 30 | def write(ftp_path, stream) 31 | puts "Received upload" 32 | puts "User: #{@user}" 33 | puts "ftp_path: #{@ftp_path}" 34 | puts "byte count: #{stream.read.size}" 35 | end 36 | 37 | end 38 | 39 | class Driver 40 | 41 | def initialize(temp_dir) 42 | @temp_dir = temp_dir 43 | end 44 | 45 | def authenticate(user, password) 46 | true 47 | end 48 | 49 | def file_system(user) 50 | FileSystem.new(user) 51 | end 52 | 53 | end 54 | 55 | Dir.mktmpdir do |temp_dir| 56 | driver = Driver.new(temp_dir) 57 | server = Ftpd::FtpServer.new(driver) 58 | server.start 59 | puts "Server listening on port #{server.bound_port}" 60 | gets 61 | end 62 | -------------------------------------------------------------------------------- /features/example/eplf.feature: -------------------------------------------------------------------------------- 1 | Feature: Example 2 | 3 | As a programmer 4 | I want to enable EPLF list format 5 | So that I can test this library with an EPLF client 6 | 7 | Background: 8 | Given the example has argument "--eplf" 9 | And the example server is started 10 | 11 | Scenario: List directory 12 | Given a successful login 13 | When the client successfully lists the directory 14 | Then the list should be in EPLF format 15 | -------------------------------------------------------------------------------- /features/example/example.feature: -------------------------------------------------------------------------------- 1 | Feature: Example 2 | 3 | As a programmer 4 | I want to connect to the example 5 | So that I can try this libary with an arbitrary FTP client 6 | 7 | Background: 8 | Given the example server is started 9 | 10 | Scenario: Normal connection 11 | Given a successful login 12 | Then the server returns no error 13 | And the client should be logged in 14 | 15 | Scenario: Fetch README 16 | Given a successful login 17 | When the client successfully gets text "README" 18 | Then the local file "README" should match the remote file 19 | -------------------------------------------------------------------------------- /features/example/read_only.feature: -------------------------------------------------------------------------------- 1 | Feature: Example 2 | 3 | As a programmer 4 | I want to start a read-only server 5 | So that nobody can modify the file system I expose 6 | 7 | Background: 8 | Given the example has argument "--read-only" 9 | And the example server is started 10 | 11 | Scenario: Fetch README 12 | Given a successful login 13 | When the client successfully gets text "README" 14 | Then the local file "README" should match the remote file 15 | 16 | Scenario: Fetch README 17 | Given a successful login 18 | When the client successfully gets text "README" 19 | Then the local file "README" should match the remote file 20 | 21 | Scenario: List 22 | Given a successful login 23 | When the client successfully lists the directory 24 | Then the file list should be in long form 25 | And the file list should contain "README" 26 | 27 | Scenario: Name List 28 | Given a successful login 29 | When the client successfully name-lists the directory 30 | Then the file list should be in short form 31 | And the file list should contain "README" 32 | 33 | Scenario: Put 34 | Given a successful login 35 | And the client has file "foo" 36 | When the client puts text "foo" 37 | Then the server returns an unimplemented command error 38 | 39 | Scenario: Put unique 40 | Given a successful login 41 | And the client has file "foo" 42 | When the client stores unique "foo" 43 | Then the server returns an unimplemented command error 44 | 45 | Scenario: Delete 46 | Given a successful login 47 | When the client deletes "README" 48 | Then the server returns an unimplemented command error 49 | 50 | Scenario: Mkdir 51 | Given a successful login 52 | When the client makes directory "foo" 53 | Then the server returns an unimplemented command error 54 | 55 | Scenario: Rename 56 | Given a successful login 57 | When the client renames "README" to "foo" 58 | Then the server returns an unimplemented command error 59 | 60 | Scenario: Rmdir 61 | Given a successful login 62 | When the client removes directory "foo" 63 | Then the server returns an unimplemented command error 64 | -------------------------------------------------------------------------------- /features/example/step_definitions/example_server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | def example_args 4 | @example_args ||= [] 5 | end 6 | 7 | Given /^the example has argument "(.*?)"$/ do |arg| 8 | example_args << arg 9 | end 10 | 11 | Given /^the example server is started$/ do 12 | @server = ExampleServer.new(example_args) 13 | end 14 | -------------------------------------------------------------------------------- /features/ftp_server/abort.feature: -------------------------------------------------------------------------------- 1 | Feature: Abort 2 | 3 | As a client 4 | I want to know this command is not supported 5 | So that I can avoid using it 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Unimplemented 11 | Given a successful connection 12 | When the client sends command "ABOR" 13 | Then the server returns an unimplemented command error 14 | -------------------------------------------------------------------------------- /features/ftp_server/allo.feature: -------------------------------------------------------------------------------- 1 | Feature: ALLO 2 | 3 | As a client 4 | I want to reserve file system space 5 | So that my put will succeed 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: With count 11 | Given a successful login 12 | When the client successfully sends "ALLO 1024" 13 | Then the server returns a not necessary reply 14 | 15 | Scenario: With count and record size 16 | Given a successful login 17 | When the client successfully sends "ALLO 1024 R 128" 18 | Then the server returns a not necessary reply 19 | 20 | Scenario: Not logged in 21 | Given a successful connection 22 | When the client sends "ALLO 1024" 23 | Then the server returns a not logged in error 24 | 25 | Scenario: Missing argument 26 | Given a successful login 27 | When the client sends "ALLO" 28 | Then the server returns a syntax error 29 | 30 | Scenario: Invalid argument 31 | Given a successful login 32 | When the client sends "ALLO XYZ" 33 | Then the server returns a syntax error 34 | -------------------------------------------------------------------------------- /features/ftp_server/cdup.feature: -------------------------------------------------------------------------------- 1 | Feature: Change Directory 2 | 3 | As a client 4 | I want to change to the parent directory 5 | 6 | Background: 7 | Given the test server is started 8 | 9 | Scenario: From subdir 10 | Given a successful login 11 | And the server has directory "subdir" 12 | And the client successfully cd's to "subdir" 13 | When the client successfully cd's up 14 | Then the current directory should be "/" 15 | 16 | Scenario: From root 17 | Given a successful login 18 | When the client successfully cd's up 19 | Then the current directory should be "/" 20 | 21 | Scenario: XCUP 22 | Given a successful login 23 | And the server has directory "subdir" 24 | And the client successfully cd's to "subdir" 25 | When the client successfully sends "XCUP" 26 | Then the current directory should be "/" 27 | 28 | Scenario: With argument 29 | Given a successful login 30 | When the client sends "CDUP abc" 31 | Then the server returns a syntax error 32 | 33 | Scenario: Not logged in 34 | Given a successful connection 35 | When the client cd's to "subdir" 36 | Then the server returns a not logged in error 37 | -------------------------------------------------------------------------------- /features/ftp_server/command_errors.feature: -------------------------------------------------------------------------------- 1 | Feature: Command Errors 2 | 3 | As a client 4 | I want good error messages 5 | So that I can figure out what went wrong 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Unknown command 11 | Given a successful connection 12 | When the client sends command "foo" 13 | Then the server returns a command unrecognized error 14 | -------------------------------------------------------------------------------- /features/ftp_server/concurrent_sessions.feature: -------------------------------------------------------------------------------- 1 | Feature: Concurrent Sessions 2 | 3 | As a client 4 | I want to start a session when there is another session 5 | So that my session doesn't have to wait on the other 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Stream 11 | Given a successful login 12 | And the server has file "ascii_unix" 13 | And the second client connects and logs in 14 | Then the second client successfully does nothing 15 | -------------------------------------------------------------------------------- /features/ftp_server/delay_after_failed_login.feature: -------------------------------------------------------------------------------- 1 | Feature: Delay After Failed Login 2 | 3 | As an administrator 4 | I want to make brute force attacks less efficient 5 | So that an attacker doesn't gain access 6 | 7 | Scenario: Failed login attempts 8 | Given the test server has a failed login delay of 0.2 seconds 9 | And the test server is started 10 | Given the client connects 11 | And the client logs in with bad user 12 | And the client logs in with bad user 13 | And the client logs in 14 | Then it should take at least 0.4 seconds 15 | 16 | Scenario: Failed login attempts 17 | Given the test server has a failed login delay of 0.0 seconds 18 | And the test server is started 19 | Given the client connects 20 | And the client logs in with bad user 21 | And the client logs in with bad user 22 | And the client logs in 23 | Then it should take less than 0.4 seconds 24 | -------------------------------------------------------------------------------- /features/ftp_server/delete.feature: -------------------------------------------------------------------------------- 1 | Feature: Delete 2 | 3 | As a client 4 | I want to delete files 5 | So that nobody can fetch them from the server 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Delete a file 11 | Given a successful login 12 | And the server has file "foo" 13 | When the client successfully deletes "foo" 14 | Then the server should not have file "foo" 15 | 16 | Scenario: Delete a file in a subdirectory 17 | Given a successful login 18 | And the server has file "foo/bar" 19 | When the client successfully deletes "foo/bar" 20 | Then the server should not have file "foo/bar" 21 | 22 | Scenario: Change current directory 23 | Given a successful login 24 | And the server has file "foo/bar" 25 | And the client successfully cd's to "foo" 26 | When the client successfully deletes "bar" 27 | Then the server should not have file "foo/bar" 28 | 29 | Scenario: Missing path 30 | Given a successful login 31 | And the server has file "foo" 32 | When the client deletes with no path 33 | Then the server returns a path required error 34 | 35 | Scenario: not found 36 | Given a successful login 37 | When the client deletes "foo" 38 | Then the server returns a not found error 39 | 40 | Scenario: Access denied 41 | Given a successful login 42 | When the client deletes "forbidden" 43 | Then the server returns an access denied error 44 | 45 | Scenario: File system error 46 | Given a successful login 47 | When the client deletes "unable" 48 | Then the server returns an action not taken error 49 | 50 | Scenario: Not logged in 51 | Given a successful connection 52 | When the client deletes "foo" 53 | Then the server returns a not logged in error 54 | 55 | Scenario: Delete not enabled 56 | Given the test server lacks delete 57 | And a successful login 58 | And the server has file "foo" 59 | When the client deletes "foo" 60 | Then the server returns an unimplemented command error 61 | -------------------------------------------------------------------------------- /features/ftp_server/directory_navigation.feature: -------------------------------------------------------------------------------- 1 | Feature: Change Directory 2 | 3 | As a client 4 | I want to change the current directory 5 | So that I can use shorter paths 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Down to subdir 11 | Given a successful login 12 | And the server has file "subdir/bar" 13 | When the client successfully cd's to "subdir" 14 | Then the current directory should be "/subdir" 15 | 16 | Scenario: Up from subdir 17 | Given a successful login 18 | And the server has file "subdir/bar" 19 | And the client successfully cd's to "subdir" 20 | When the client successfully cd's to ".." 21 | Then the current directory should be "/" 22 | 23 | Scenario: Up from root 24 | Given a successful login 25 | When the client successfully cd's to ".." 26 | Then the current directory should be "/" 27 | 28 | Scenario: XPWD 29 | Given a successful login 30 | And the server has directory "subdir" 31 | When the client successfully cd's to "subdir" 32 | Then the XPWD directory should be "/subdir" 33 | 34 | Scenario: XCWD 35 | Given a successful login 36 | And the server has directory "subdir" 37 | When the client successfully sends "XCWD subdir" 38 | Then the current directory should be "/subdir" 39 | 40 | Scenario: Change to file 41 | Given a successful login 42 | And the server has file "baz" 43 | When the client cd's to "baz" 44 | Then the server returns a not a directory error 45 | 46 | Scenario: No such directory 47 | Given a successful login 48 | When the client cd's to "subdir" 49 | Then the server returns a not found error 50 | 51 | Scenario: Access denied 52 | Given a successful login 53 | When the client cd's to "forbidden" 54 | Then the server returns an access denied error 55 | 56 | Scenario: Not logged in 57 | Given a successful connection 58 | When the client cd's to "subdir" 59 | Then the server returns a not logged in error 60 | -------------------------------------------------------------------------------- /features/ftp_server/disconnect_after_failed_logins.feature: -------------------------------------------------------------------------------- 1 | Feature: Disconnect After Failed Logins 2 | 3 | As an administrator 4 | I want to make brute force attacks less efficient 5 | So that an attacker doesn't gain access 6 | 7 | Scenario: Disconnected after maximum failed attempts 8 | Given the test server has a max of 3 failed login attempts 9 | And the test server is started 10 | Given the client connects 11 | And the client logs in with bad user 12 | And the client logs in with bad user 13 | When the client logs in with bad user 14 | Then the server returns a server unavailable error 15 | And the client should not be connected 16 | 17 | Scenario: No maximum configured 18 | Given the test server has no max failed login attempts 19 | And the test server is started 20 | Given the client connects 21 | And the client logs in with bad user 22 | And the client logs in with bad user 23 | And the client logs in with bad user 24 | Then the server returns a login incorrect error 25 | And the client should be connected 26 | -------------------------------------------------------------------------------- /features/ftp_server/eprt.feature: -------------------------------------------------------------------------------- 1 | Feature: EPRT 2 | 3 | As a programmer 4 | I want good error messages 5 | So that I can correct problems 6 | 7 | Background: 8 | Given the stack supports ipv6 9 | Given the test server is bound to "::" 10 | Given the test server is started 11 | 12 | Scenario: Port 1024 13 | Given a successful login 14 | Then the client successfully sends "EPRT |1|1.2.3.4|1024|" 15 | 16 | Scenario: Port 1023; low ports disallowed 17 | Given the test server disallows low data ports 18 | And a successful login 19 | When the client sends "EPRT |1|2.3.4.3|255|" 20 | Then the server returns an unimplemented parameter error 21 | 22 | Scenario: Port out of range 23 | Given a successful login 24 | When the client sends "EPRT |1|2.3.4.5|65536|" 25 | Then the server returns an unimplemented parameter error 26 | 27 | Scenario: Port 1023; low ports allowed 28 | Given the test server allows low data ports 29 | And a successful login 30 | Then the client successfully sends "EPRT |1|2.3.4.3|255|" 31 | 32 | Scenario: Not logged in 33 | Given a successful connection 34 | When the client sends "EPRT |1|2.3.4.5|6|" 35 | Then the server returns a not logged in error 36 | 37 | Scenario: Too few parts 38 | Given a successful login 39 | When the client sends "EPRT |1|2.3.4|" 40 | Then the server returns a syntax error 41 | 42 | Scenario: Too many parts 43 | Given a successful login 44 | When the client sends "EPRT |1|2.3.4|5|6|" 45 | Then the server returns a syntax error 46 | 47 | Scenario: Unknown network protocol 48 | Given a successful login 49 | When the client sends "EPRT |3|2.3.4.5|6|" 50 | Then the server returns a network protocol not supported error 51 | 52 | Scenario: After "EPSV ALL" 53 | Given a successful login 54 | Given the client successfully sends "EPSV ALL" 55 | When the client sends "EPRT |1|2.3.4.5|6|" 56 | Then the server sends a not allowed after epsv all error 57 | -------------------------------------------------------------------------------- /features/ftp_server/epsv.feature: -------------------------------------------------------------------------------- 1 | Feature: EPSV 2 | 3 | As a programmer 4 | I want good error messages 5 | So that I can correct problems 6 | 7 | Background: 8 | Given the stack supports ipv6 9 | Given the test server is bound to "::" 10 | And the test server is started 11 | 12 | Scenario: No argument 13 | Given a successful login 14 | Then the client successfully sends "EPSV" 15 | 16 | Scenario: Explicit IPV4 17 | Given a successful login 18 | Then the client successfully sends "EPSV 1" 19 | 20 | Scenario: Explicit IPV6 21 | Given a successful login 22 | Then the client successfully sends "EPSV 2" 23 | 24 | Scenario: After "EPSV ALL" 25 | Given a successful login 26 | Given the client successfully sends "EPSV ALL" 27 | Then the client successfully sends "EPSV" 28 | 29 | Scenario: Not logged in 30 | Given a successful connection 31 | When the client sends "EPSV" 32 | Then the server returns a not logged in error 33 | 34 | Scenario: Unknown network protocol 35 | Given a successful login 36 | When the client sends "EPSV 99" 37 | Then the server returns a network protocol not supported error 38 | -------------------------------------------------------------------------------- /features/ftp_server/features.feature: -------------------------------------------------------------------------------- 1 | Feature: Features 2 | 3 | As a client 4 | I want to know what FTP extension the server supports 5 | So that I can use them without trial-and-error 6 | 7 | Background: 8 | 9 | Scenario: TLS Disabled 10 | Given the test server is started 11 | And the client connects 12 | When the client successfully requests features 13 | Then the response should not include TLS features 14 | 15 | Scenario: TLS Enabled 16 | Given the test server has TLS mode "explicit" 17 | And the test server is started 18 | And the client connects 19 | When the client successfully requests features 20 | Then the response should include TLS features 21 | 22 | Scenario: Argument given 23 | Given the test server is started 24 | And the client connects 25 | When the client sends "FEAT FOO" 26 | Then the server returns a syntax error 27 | 28 | Scenario: IPV6 Extensions 29 | Given the test server is started 30 | When the client successfully requests features 31 | Then the response should include feature "EPRT" 32 | And the response should include feature "EPSV" 33 | 34 | Scenario: RFC 3659 Extensions 35 | Given the test server is started 36 | When the client successfully requests features 37 | Then the response should include feature "SIZE" 38 | Then the response should include feature "MDTM" 39 | -------------------------------------------------------------------------------- /features/ftp_server/file_structure.feature: -------------------------------------------------------------------------------- 1 | Feature: File Structure 2 | 3 | As a server 4 | I want to accept the obsolute file structure (STRU) command 5 | For compatability 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: File 11 | Given a successful login 12 | And the server has file "ascii_unix" 13 | When the client successfully sets file structure "F" 14 | And the client successfully gets text "ascii_unix" 15 | Then the remote file "ascii_unix" should match the local file 16 | 17 | Scenario: Record 18 | Given a successful login 19 | And the server has file "ascii_unix" 20 | When the client sets file structure "R" 21 | Then the server returns a file structure not implemented error 22 | 23 | Scenario: Page 24 | Given a successful login 25 | And the server has file "ascii_unix" 26 | When the client sets file structure "P" 27 | Then the server returns a file structure not implemented error 28 | 29 | Scenario: Invalid 30 | Given a successful login 31 | And the server has file "ascii_unix" 32 | When the client sets file structure "*" 33 | Then the server returns an invalid file structure error 34 | 35 | Scenario: Not logged in 36 | Given a successful connection 37 | When the client sets file structure "F" 38 | Then the server returns a not logged in error 39 | 40 | Scenario: Missing parameter 41 | Given a successful login 42 | When the client sets file structure with no parameter 43 | Then the server returns a syntax error 44 | -------------------------------------------------------------------------------- /features/ftp_server/get.feature: -------------------------------------------------------------------------------- 1 | Feature: Get 2 | 3 | As a client 4 | I want to securely get a file 5 | So that I have it on my computer 6 | But nobody else can 7 | 8 | Background: 9 | Given the test server is started 10 | 11 | Scenario: ASCII file with *nix line endings 12 | Given a successful login 13 | And the server has file "ascii_unix" 14 | When the client successfully gets text "ascii_unix" 15 | Then the local file "ascii_unix" should match the remote file 16 | And the local file "ascii_unix" should have unix line endings 17 | 18 | Scenario: ASCII file with windows line endings 19 | Given a successful login 20 | And the server has file "ascii_windows" 21 | When the client successfully gets text "ascii_windows" 22 | Then the local file "ascii_windows" should match the remote file 23 | And the local file "ascii_windows" should have unix line endings 24 | 25 | Scenario: Binary file 26 | Given a successful login 27 | And the server has file "binary" 28 | When the client successfully gets binary "binary" 29 | Then the local file "binary" should exactly match the remote file 30 | 31 | Scenario: Passive 32 | Given a successful login 33 | And the server has file "ascii_unix" 34 | And the client is in passive mode 35 | When the client successfully gets text "ascii_unix" 36 | Then the local file "ascii_unix" should match the remote file 37 | 38 | Scenario: File in subdirectory 39 | Given a successful login 40 | And the server has file "foo/ascii_unix" 41 | Then the client successfully gets text "foo/ascii_unix" 42 | 43 | Scenario: Non-root working directory 44 | Given a successful login 45 | And the server has file "foo/ascii_unix" 46 | And the client successfully cd's to "foo" 47 | When the client successfully gets text "ascii_unix" 48 | Then the remote file "foo/ascii_unix" should match the local file 49 | 50 | Scenario: Access denied 51 | Given a successful login 52 | When the client gets text "forbidden" 53 | Then the server returns an access denied error 54 | 55 | Scenario: Missing file 56 | Given a successful login 57 | When the client gets text "foo" 58 | Then the server returns a not found error 59 | 60 | Scenario: Not logged in 61 | Given a successful connection 62 | When the client gets text "foo" 63 | Then the server returns a not logged in error 64 | 65 | Scenario: Missing path 66 | Given a successful login 67 | When the client gets with no path 68 | Then the server returns a syntax error 69 | 70 | Scenario: File system error 71 | Given a successful login 72 | When the client gets text "unable" 73 | Then the server returns an action not taken error 74 | 75 | Scenario: Read not enabled 76 | Given the test server lacks read 77 | And a successful login 78 | And the server has file "foo" 79 | When the client gets text "foo" 80 | Then the server returns an unimplemented command error 81 | -------------------------------------------------------------------------------- /features/ftp_server/get_ipv6.feature: -------------------------------------------------------------------------------- 1 | Feature: Get IPV6 2 | 3 | As a client 4 | I want to get a file 5 | So that I have it on my computer 6 | 7 | Background: 8 | Given the stack supports ipv6 9 | 10 | Scenario: Active 11 | Given the test server is bound to "::1" 12 | And the test server is started 13 | And a successful login 14 | And the server has file "ascii_unix" 15 | And the client is in active mode 16 | When the client successfully gets text "ascii_unix" 17 | Then the local file "ascii_unix" should match the remote file 18 | 19 | Scenario: Passive 20 | Given the test server is bound to "::1" 21 | And the test server is started 22 | And a successful login 23 | And the server has file "ascii_unix" 24 | And the client is in passive mode 25 | When the client successfully gets text "ascii_unix" 26 | Then the local file "ascii_unix" should match the remote file 27 | 28 | Scenario: Active, TLS 29 | Given the test server is bound to "::1" 30 | And the test server has TLS mode "explicit" 31 | And the test server is started 32 | And a successful login 33 | And the server has file "ascii_unix" 34 | And the client is in active mode 35 | When the client successfully gets text "ascii_unix" 36 | Then the local file "ascii_unix" should match the remote file 37 | 38 | Scenario: Passive, TLS 39 | Given the test server is bound to "::1" 40 | And the test server has TLS mode "explicit" 41 | And the test server is started 42 | And a successful login 43 | And the server has file "ascii_unix" 44 | And the client is in passive mode 45 | When the client successfully gets text "ascii_unix" 46 | Then the local file "ascii_unix" should match the remote file 47 | -------------------------------------------------------------------------------- /features/ftp_server/get_tls.feature: -------------------------------------------------------------------------------- 1 | Feature: Get TLS 2 | 3 | As a client 4 | I want to get a file 5 | So that I have it on my computer 6 | 7 | Background: 8 | Given the test server has TLS mode "explicit" 9 | And the test server is started 10 | 11 | Scenario: Active 12 | Given a successful login with explicit TLS 13 | And the server has file "ascii_unix" 14 | And the client is in active mode 15 | When the client successfully gets text "ascii_unix" 16 | Then the local file "ascii_unix" should match the remote file 17 | 18 | Scenario: Passive 19 | Given a successful login with explicit TLS 20 | And the server has file "ascii_unix" 21 | And the client is in passive mode 22 | When the client successfully gets text "ascii_unix" 23 | Then the local file "ascii_unix" should match the remote file 24 | -------------------------------------------------------------------------------- /features/ftp_server/help.feature: -------------------------------------------------------------------------------- 1 | Feature: Help 2 | 3 | As a client 4 | I want to ask for help 5 | So that I can know which commands are supported 6 | 7 | Background: 8 | Given the test server is started 9 | And a successful connection 10 | 11 | Scenario: No argument 12 | When the client successfully asks for help 13 | Then the server should return a list of commands 14 | 15 | Scenario: Known command 16 | When the client successfully asks for help for "NOOP" 17 | Then the server should return help for "NOOP" 18 | 19 | Scenario: Unknown command 20 | When the client successfully asks for help for "FOO" 21 | Then the server should return no help for "FOO" 22 | -------------------------------------------------------------------------------- /features/ftp_server/implicit_tls.feature: -------------------------------------------------------------------------------- 1 | Feature: Implicit TLS 2 | 3 | As a server 4 | I want to use implicit TLS 5 | Because I must serve out-of-date clients 6 | 7 | Background: 8 | Given the test server has TLS mode "implicit" 9 | And the test server is started 10 | 11 | Scenario: Active 12 | Given a successful login with implicit TLS 13 | And the client has file "ascii_unix" 14 | And the client is in active mode 15 | When the client successfully puts text "ascii_unix" 16 | Then the remote file "ascii_unix" should match the local file 17 | 18 | Scenario: Passive 19 | Given a successful login with implicit TLS 20 | And the client has file "ascii_unix" 21 | And the client is in passive mode 22 | When the client successfully puts text "ascii_unix" 23 | Then the remote file "ascii_unix" should match the local file 24 | -------------------------------------------------------------------------------- /features/ftp_server/invertability.feature: -------------------------------------------------------------------------------- 1 | Feature: Get 2 | 3 | As a client 4 | I want a file to be the same when I get it as it was when I put it 5 | So that I can use the FTP server for storage 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Binary file 11 | Given a successful login 12 | And the client has file "binary" 13 | When the client successfully puts binary "binary" 14 | And the client successfully gets binary "binary" 15 | Then the local file "binary" should match its template 16 | -------------------------------------------------------------------------------- /features/ftp_server/list.feature: -------------------------------------------------------------------------------- 1 | Feature: List 2 | 3 | As a client 4 | I want to list files 5 | So that I can see what file to transfer 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Implicit 11 | Given a successful login 12 | And the server has file "foo" 13 | And the server has file "bar" 14 | When the client successfully lists the directory 15 | Then the file list should be in long form 16 | And the file list should contain "foo" 17 | And the file list should contain "bar" 18 | 19 | Scenario: Root 20 | Given a successful login 21 | And the server has file "foo" 22 | And the server has file "bar" 23 | When the client successfully lists the directory "/" 24 | Then the file list should be in long form 25 | And the file list should contain "foo" 26 | And the file list should contain "bar" 27 | 28 | Scenario: Parent of root 29 | Given a successful login 30 | And the server has file "foo" 31 | And the server has file "bar" 32 | When the client successfully lists the directory "/.." 33 | Then the file list should be in long form 34 | And the file list should contain "foo" 35 | And the file list should contain "bar" 36 | 37 | Scenario: Subdir 38 | Given a successful login 39 | And the server has file "subdir/foo" 40 | When the client successfully lists the directory "subdir" 41 | Then the file list should be in long form 42 | And the file list should contain "foo" 43 | 44 | Scenario: After CWD 45 | Given a successful login 46 | And the server has file "subdir/foo" 47 | And the client successfully cd's to "subdir" 48 | When the client successfully lists the directory 49 | Then the file list should be in long form 50 | And the file list should contain "foo" 51 | 52 | Scenario: Glob 53 | Given a successful login 54 | And the server has file "foo" 55 | And the server has file "bar" 56 | When the client successfully lists the directory "f*" 57 | Then the file list should be in long form 58 | And the file list should contain "foo" 59 | And the file list should not contain "bar" 60 | 61 | Scenario: Passive 62 | Given a successful login 63 | And the server has file "foo" 64 | And the server has file "bar" 65 | And the client is in passive mode 66 | When the client successfully lists the directory 67 | Then the file list should be in long form 68 | And the file list should contain "foo" 69 | And the file list should contain "bar" 70 | 71 | Scenario: -a 72 | Given a successful login 73 | And the server has file "foo" 74 | And the server has file "bar" 75 | When the client successfully lists the directory "-a" 76 | Then the file list should be in long form 77 | And the file list should contain "foo" 78 | And the file list should contain "bar" 79 | 80 | Scenario: Missing directory 81 | Given a successful login 82 | When the client successfully lists the directory "missing/file" 83 | Then the file list should be empty 84 | 85 | Scenario: Not logged in 86 | Given a successful connection 87 | When the client lists the directory 88 | Then the server returns a not logged in error 89 | 90 | Scenario: List not enabled 91 | Given the test server lacks list 92 | And a successful login 93 | When the client lists the directory 94 | Then the server returns an unimplemented command error 95 | -------------------------------------------------------------------------------- /features/ftp_server/list_tls.feature: -------------------------------------------------------------------------------- 1 | Feature: List TLS 2 | 3 | As a client 4 | I want to list files 5 | So that I can see what file to transfer 6 | 7 | Background: 8 | Given the test server has TLS mode "explicit" 9 | And the test server is started 10 | 11 | Scenario: Active 12 | Given a successful login with explicit TLS 13 | And the server has file "foo" 14 | And the server has file "bar" 15 | And the client is in active mode 16 | When the client successfully lists the directory 17 | Then the file list should be in long form 18 | And the file list should contain "foo" 19 | And the file list should contain "bar" 20 | 21 | Scenario: Passive 22 | Given a successful login with explicit TLS 23 | And the server has file "foo" 24 | And the server has file "bar" 25 | And the client is in passive mode 26 | When the client successfully lists the directory 27 | Then the file list should be in long form 28 | And the file list should contain "foo" 29 | And the file list should contain "bar" 30 | -------------------------------------------------------------------------------- /features/ftp_server/logging.feature: -------------------------------------------------------------------------------- 1 | Feature: Logging 2 | 3 | As a programmer 4 | I want to see logging output 5 | So that I can fix FTP protocol problems 6 | 7 | Scenario: Logging enabled 8 | Given the test server has logging enabled 9 | And the test server is started 10 | And a successful login 11 | Then the server should have written log output 12 | -------------------------------------------------------------------------------- /features/ftp_server/login_auth_level_account.feature: -------------------------------------------------------------------------------- 1 | Feature: Login 2 | 3 | As a client 4 | I want to log in 5 | So that I can transfer files 6 | 7 | Background: 8 | Given the test server has auth level "AUTH_ACCOUNT" 9 | And the test server is started 10 | 11 | Scenario: Normal connection 12 | Given a successful login 13 | Then the server returns no error 14 | And the client should be logged in 15 | 16 | Scenario: Bad user 17 | Given the client connects 18 | When the client logs in with bad user 19 | Then the server returns a login incorrect error 20 | And the client should not be logged in 21 | 22 | Scenario: Bad password 23 | Given a successful connection 24 | When the client logs in with bad password 25 | Then the server returns a login incorrect error 26 | And the client should not be logged in 27 | 28 | Scenario: Bad account 29 | Given a successful connection 30 | When the client logs in with bad account 31 | Then the server returns a login incorrect error 32 | And the client should not be logged in 33 | 34 | Scenario: ACCT without parameter 35 | Given a successful connection 36 | And the client sends a user 37 | And the client sends a password 38 | When the client sends "ACCT" 39 | Then the server returns a syntax error 40 | 41 | Scenario: PASS not followed by ACCT 42 | Given a successful connection 43 | And the client sends a user 44 | And the client sends a password 45 | When the client sends "NOOP" 46 | Then the server returns a bad sequence error 47 | 48 | Scenario: ACCT out of sequence 49 | Given a successful connection 50 | When the client sends "ACCT" 51 | Then the server returns a bad sequence error 52 | -------------------------------------------------------------------------------- /features/ftp_server/login_auth_level_password.feature: -------------------------------------------------------------------------------- 1 | Feature: Login 2 | 3 | As a client 4 | I want to log in 5 | So that I can transfer files 6 | 7 | Background: 8 | Given the test server has auth level "AUTH_PASSWORD" 9 | And the test server is started 10 | 11 | Scenario: Normal connection 12 | Given a successful login 13 | Then the server returns no error 14 | And the client should be logged in 15 | 16 | Scenario: Bad user 17 | Given the client connects 18 | When the client logs in with bad user 19 | Then the server returns a login incorrect error 20 | And the client should not be logged in 21 | 22 | Scenario: Bad password 23 | Given a successful connection 24 | When the client logs in with bad password 25 | Then the server returns a login incorrect error 26 | And the client should not be logged in 27 | 28 | Scenario: Already logged in 29 | Given a successful login 30 | When the client logs in 31 | Then the server returns a bad sequence error 32 | And the client should be logged in 33 | 34 | Scenario: PASS when already logged in 35 | Given a successful login 36 | When the client sends a password 37 | Then the server returns a bad sequence error 38 | 39 | Scenario: PASS after failed login 40 | Given a failed login 41 | When the client sends a password 42 | Then the server returns a bad sequence error 43 | 44 | Scenario: PASS without USER 45 | Given a successful connection 46 | And the client sends a password 47 | Then the server returns a bad sequence error 48 | 49 | Scenario: PASS without parameter 50 | Given a successful connection 51 | And the client sends a user 52 | When the client sends a password with no parameter 53 | Then the server returns a syntax error 54 | 55 | Scenario: USER not followed by PASS 56 | Given a successful connection 57 | And the client sends a user 58 | When the client sends "NOOP" 59 | Then the server returns a bad sequence error 60 | -------------------------------------------------------------------------------- /features/ftp_server/login_auth_level_user.feature: -------------------------------------------------------------------------------- 1 | Feature: Login 2 | 3 | As a client 4 | I want to log in 5 | So that I can transfer files 6 | 7 | Background: 8 | Given the test server has auth level "AUTH_USER" 9 | And the test server is started 10 | 11 | Scenario: Normal connection 12 | Given a successful login 13 | Then the server returns no error 14 | And the client should be logged in 15 | 16 | Scenario: Bad user 17 | Given the client connects 18 | When the client logs in with bad user 19 | Then the server returns a login incorrect error 20 | And the client should not be logged in 21 | 22 | Scenario: Already logged in 23 | Given a successful login 24 | When the client logs in 25 | Then the server returns a bad sequence error 26 | And the client should be logged in 27 | 28 | Scenario: USER without parameter 29 | Given a successful connection 30 | And the client sends a user with no parameter 31 | Then the server returns a syntax error 32 | -------------------------------------------------------------------------------- /features/ftp_server/max_connections.feature: -------------------------------------------------------------------------------- 1 | Feature: Max Connections 2 | 3 | As an administrator 4 | I want to limit the number of connections 5 | To prevent overload 6 | 7 | Scenario: Total connections 8 | Given the test server has max_connections set to 2 9 | And the test server is started 10 | And the 1st client connects 11 | And the 2nd client connects 12 | When the 3rd client tries to connect 13 | Then the server returns a too many connections error 14 | 15 | Scenario: Connections per user 16 | And the test server has max_connections_per_ip set to 1 17 | And the test server is started 18 | And the 1st client connects from 127.0.0.1 19 | And the 2nd client connects from 127.0.0.2 20 | When the 3rd client tries to connect from 127.0.0.2 21 | Then the server returns a too many connections error 22 | 23 | Scenario: TLS 24 | Given the test server has max_connections set to 2 25 | And the test server has TLS mode "explicit" 26 | And the test server is started 27 | And the 1st client connects 28 | And the 2nd client connects 29 | When the 3rd client tries to connect 30 | Then the server returns a too many connections error 31 | 32 | Scenario: Connections per user, TLS 33 | And the test server has max_connections_per_ip set to 1 34 | And the test server has TLS mode "explicit" 35 | And the test server is started 36 | And the 1st client connects from 127.0.0.1 37 | And the 2nd client connects from 127.0.0.2 38 | When the 3rd client tries to connect from 127.0.0.2 39 | Then the server returns a too many connections error 40 | -------------------------------------------------------------------------------- /features/ftp_server/mdtm.feature: -------------------------------------------------------------------------------- 1 | Feature: MDTM 2 | 3 | As a client 4 | I want to get a file's modification time 5 | So that I can detect when it changes 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: File in current directory 11 | Given a successful login 12 | And the server has file "ascii_unix" 13 | And the file "ascii_unix" has mtime "2014-01-02 13:14:15.123456" 14 | When the client successfully gets mtime of "ascii_unix" 15 | Then the reported mtime should be "20140102131415" 16 | 17 | Scenario: File in subdirectory 18 | Given a successful login 19 | And the server has file "foo/ascii_unix" 20 | Then the client successfully gets mtime of "foo/ascii_unix" 21 | 22 | Scenario: Non-root working directory 23 | Given a successful login 24 | And the server has file "foo/ascii_unix" 25 | And the client successfully cd's to "foo" 26 | Then the client successfully gets mtime of "ascii_unix" 27 | 28 | Scenario: Access denied 29 | Given a successful login 30 | When the client gets mtime of "forbidden" 31 | Then the server returns an access denied error 32 | 33 | Scenario: Missing file 34 | Given a successful login 35 | When the client gets mtime of "foo" 36 | Then the server returns a not found error 37 | 38 | Scenario: Not logged in 39 | Given a successful connection 40 | When the client gets mtime of "foo" 41 | Then the server returns a not logged in error 42 | 43 | Scenario: Missing path 44 | Given a successful login 45 | When the client gets mtime with no path 46 | Then the server returns a syntax error 47 | 48 | Scenario: List not enabled 49 | Given the test server lacks list 50 | And a successful login 51 | And the server has file "foo" 52 | When the client gets mtime of "foo" 53 | Then the server returns an unimplemented command error 54 | -------------------------------------------------------------------------------- /features/ftp_server/mkdir.feature: -------------------------------------------------------------------------------- 1 | Feature: Make directory 2 | 3 | As a client 4 | I want to create a directory 5 | So that I can categorize my uploads 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Make directory 11 | Given a successful login 12 | When the client successfully makes directory "foo" 13 | Then the server has directory "foo" 14 | 15 | Scenario: Directory of a directory 16 | Given a successful login 17 | And the server has directory "foo" 18 | When the client successfully makes directory "foo/bar" 19 | Then the server has directory "foo/bar" 20 | 21 | Scenario: After cwd 22 | Given a successful login 23 | And the server has directory "foo" 24 | And the client successfully cd's to "foo" 25 | When the client successfully makes directory "bar" 26 | Then the server has directory "foo/bar" 27 | 28 | Scenario: XMKD 29 | Given a successful login 30 | When the client successfully sends "XMKD foo" 31 | Then the server has directory "foo" 32 | 33 | Scenario: Missing directory 34 | Given a successful login 35 | When the client makes directory "foo/bar" 36 | Then the server returns a not found error 37 | 38 | Scenario: Not logged in 39 | Given a successful connection 40 | When the client makes directory "foo" 41 | Then the server returns a not logged in error 42 | 43 | Scenario: Already exists 44 | Given a successful login 45 | And the server has directory "foo" 46 | When the client makes directory "foo" 47 | Then the server returns an already exists error 48 | 49 | Scenario: Directory of a file 50 | Given a successful login 51 | And the server has file "foo" 52 | When the client makes directory "foo/bar" 53 | Then the server returns a not a directory error 54 | 55 | Scenario: Mkdir not enabled 56 | Given the test server lacks mkdir 57 | And a successful login 58 | When the client makes directory "foo" 59 | Then the server returns an unimplemented command error 60 | 61 | Scenario: Missing path 62 | Given a successful login 63 | When the client sends "MKD" 64 | Then the server returns a syntax error 65 | 66 | Scenario: Access denied 67 | Given a successful login 68 | When the client makes directory "forbidden" 69 | Then the server returns an access denied error 70 | 71 | -------------------------------------------------------------------------------- /features/ftp_server/mode.feature: -------------------------------------------------------------------------------- 1 | Feature: Mode 2 | 3 | As a client 4 | I want to set the file transfer mode 5 | So that can optimize the transfer 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Stream 11 | Given a successful login 12 | And the server has file "ascii_unix" 13 | When the client successfully sets mode "S" 14 | And the client successfully gets text "ascii_unix" 15 | Then the remote file "ascii_unix" should match the local file 16 | 17 | Scenario: Block 18 | Given a successful login 19 | And the server has file "ascii_unix" 20 | When the client sets mode "B" 21 | Then the server returns a mode not implemented error 22 | 23 | Scenario: Compressed 24 | Given a successful login 25 | And the server has file "ascii_unix" 26 | When the client sets mode "C" 27 | Then the server returns a mode not implemented error 28 | 29 | Scenario: Invalid 30 | Given a successful login 31 | And the server has file "ascii_unix" 32 | When the client sets mode "*" 33 | Then the server returns an invalid mode error 34 | 35 | Scenario: Not logged in 36 | Given a successful connection 37 | When the client sets mode "S" 38 | Then the server returns a not logged in error 39 | 40 | Scenario: Missing parameter 41 | Given a successful login 42 | When the client sets mode with no parameter 43 | Then the server returns a syntax error 44 | -------------------------------------------------------------------------------- /features/ftp_server/name_list.feature: -------------------------------------------------------------------------------- 1 | Feature: Name List 2 | 3 | As a client 4 | I want to list file names 5 | So that I can see what file to transfer 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Implicit 11 | Given a successful login 12 | And the server has file "foo" 13 | And the server has file "bar" 14 | When the client successfully name-lists the directory 15 | Then the file list should be in short form 16 | And the file list should contain "foo" 17 | And the file list should contain "bar" 18 | 19 | Scenario: Root 20 | Given a successful login 21 | And the server has file "foo" 22 | And the server has file "bar" 23 | When the client successfully name-lists the directory "/" 24 | Then the file list should be in short form 25 | And the file list should contain "foo" 26 | And the file list should contain "bar" 27 | 28 | Scenario: Parent of root 29 | Given a successful login 30 | And the server has file "foo" 31 | And the server has file "bar" 32 | When the client successfully name-lists the directory "/.." 33 | Then the file list should be in short form 34 | And the file list should contain "foo" 35 | And the file list should contain "bar" 36 | 37 | Scenario: Subdir 38 | Given a successful login 39 | And the server has file "subdir/foo" 40 | When the client successfully name-lists the directory "subdir" 41 | Then the file list should be in short form 42 | And the file list should contain "foo" 43 | 44 | Scenario: '-a' 45 | Given a successful login 46 | And the server has file "foo" 47 | And the server has file "bar" 48 | When the client successfully name-lists the directory "-a" 49 | Then the file list should be in short form 50 | And the file list should contain "foo" 51 | And the file list should contain "bar" 52 | 53 | Scenario: Passive 54 | Given a successful login 55 | And the server has file "foo" 56 | And the server has file "bar" 57 | And the client is in passive mode 58 | When the client successfully name-lists the directory 59 | Then the file list should be in short form 60 | And the file list should contain "foo" 61 | And the file list should contain "bar" 62 | 63 | Scenario: Missing directory 64 | Given a successful login 65 | When the client successfully name-lists the directory "missing/file" 66 | Then the file list should be empty 67 | 68 | Scenario: Not logged in 69 | Given a successful connection 70 | When the client name-lists the directory 71 | Then the server returns a not logged in error 72 | 73 | Scenario: List not enabled 74 | Given the test server lacks list 75 | And a successful login 76 | When the client name-lists the directory 77 | Then the server returns an unimplemented command error 78 | -------------------------------------------------------------------------------- /features/ftp_server/name_list_tls.feature: -------------------------------------------------------------------------------- 1 | Feature: Name List TLS 2 | 3 | As a client 4 | I want to securely list file names 5 | So that I can see what file to transfer 6 | And nobody else can 7 | 8 | Background: 9 | Given the test server has TLS mode "explicit" 10 | And the test server is started 11 | 12 | Scenario: Active 13 | Given a successful login with explicit TLS 14 | And the server has file "foo" 15 | And the server has file "bar" 16 | And the client is in active mode 17 | When the client successfully name-lists the directory 18 | Then the file list should be in short form 19 | And the file list should contain "foo" 20 | And the file list should contain "bar" 21 | 22 | Scenario: Passive 23 | Given a successful login with explicit TLS 24 | And the server has file "foo" 25 | And the server has file "bar" 26 | And the client is in passive mode 27 | When the client successfully name-lists the directory 28 | Then the file list should be in short form 29 | And the file list should contain "foo" 30 | And the file list should contain "bar" 31 | -------------------------------------------------------------------------------- /features/ftp_server/noop.feature: -------------------------------------------------------------------------------- 1 | Feature: No Operation 2 | 3 | As a client 4 | I want to keep the connection alive 5 | So that I don't have to log in so often 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: NOP 11 | Given a successful connection 12 | Then the client successfully does nothing 13 | 14 | Scenario: With a parameter 15 | Given a successful connection 16 | When the client does nothing with a parameter 17 | Then the server returns a syntax error 18 | -------------------------------------------------------------------------------- /features/ftp_server/options.feature: -------------------------------------------------------------------------------- 1 | Feature: Options 2 | 3 | As a client 4 | I want to know set options 5 | To tailor the server's behavior 6 | 7 | Background: 8 | Given the test server is started 9 | And the client connects 10 | 11 | Scenario: No argument 12 | When the client sends "OPTS" 13 | Then the server returns a syntax error 14 | 15 | Scenario: Unknown option command 16 | When the client sets option "ABC" 17 | Then the server returns a bad option error 18 | -------------------------------------------------------------------------------- /features/ftp_server/pasv.feature: -------------------------------------------------------------------------------- 1 | Feature: PASV 2 | 3 | As a programmer 4 | I want good error messages 5 | So that I can correct problems 6 | 7 | Scenario: No argument 8 | Given the test server is started 9 | Given a successful login 10 | Then the client successfully sends "PASV" 11 | 12 | Scenario: After "EPSV ALL" 13 | Given the test server is started 14 | Given a successful login 15 | Given the client successfully sends "EPSV ALL" 16 | When the client sends "PASV" 17 | Then the server sends a not allowed after epsv all error 18 | 19 | Scenario: Not logged in 20 | Given the test server is started 21 | Given a successful connection 22 | When the client sends "EPSV" 23 | Then the server returns a not logged in error 24 | 25 | Scenario: Configured with NAT IP 26 | Given the test server has a NAT IP of 10.1.2.3 27 | Given the test server is started 28 | And a successful login 29 | When the client successfully sends "PASV" 30 | Then the server advertises passive IP 10.1.2.3 31 | -------------------------------------------------------------------------------- /features/ftp_server/port.feature: -------------------------------------------------------------------------------- 1 | Feature: PORT 2 | 3 | As a programmer 4 | I want good error messages 5 | So that I can correct problems 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Port 1024 11 | Given a successful login 12 | Then the client successfully sends "PORT 1,2,3,4,4,0" 13 | 14 | Scenario: Port 1023; low ports disallowed 15 | Given the test server disallows low data ports 16 | And a successful login 17 | When the client sends "PORT 1,2,3,4,3,255" 18 | Then the server returns an unimplemented parameter error 19 | 20 | Scenario: Port 1023; low ports allowed 21 | Given the test server allows low data ports 22 | And a successful login 23 | Then the client successfully sends "PORT 1,2,3,4,3,255" 24 | 25 | Scenario: Not logged in 26 | Given a successful connection 27 | When the client sends PORT "1,2,3,4,5,6" 28 | Then the server returns a not logged in error 29 | 30 | Scenario: Incorrect number of bytes 31 | Given a successful login 32 | When the client sends PORT "1,2,3,4,5" 33 | Then the server returns a syntax error 34 | 35 | Scenario: Ill formatted byte 36 | Given a successful login 37 | When the client sends PORT "1,2,3,4,5,0006" 38 | Then the server returns a syntax error 39 | 40 | Scenario: Byte out of range 41 | Given a successful login 42 | When the client sends PORT "1,2,3,4,5,256" 43 | Then the server returns a syntax error 44 | 45 | Scenario: After "EPSV ALL" 46 | Given a successful login 47 | Given the client successfully sends "EPSV ALL" 48 | When the client sends "PORT 1,2,3,4,4,0" 49 | Then the server sends a not allowed after epsv all error 50 | -------------------------------------------------------------------------------- /features/ftp_server/put.feature: -------------------------------------------------------------------------------- 1 | Feature: Put 2 | 3 | As a client 4 | I want to upload a file 5 | So that someone else can have it 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: ASCII file with *nix line endings 11 | Given a successful login 12 | And the client has file "ascii_unix" 13 | When the client successfully puts text "ascii_unix" 14 | Then the remote file "ascii_unix" should match the local file 15 | And the remote file "ascii_unix" should have unix line endings 16 | 17 | Scenario: ASCII file with windows line endings 18 | Given a successful login 19 | And the client has file "ascii_windows" 20 | When the client successfully puts text "ascii_windows" 21 | Then the remote file "ascii_windows" should match the local file 22 | And the remote file "ascii_windows" should have unix line endings 23 | 24 | Scenario: Binary file 25 | Given a successful login 26 | And the client has file "binary" 27 | When the client successfully puts binary "binary" 28 | Then the remote file "binary" should exactly match the local file 29 | 30 | Scenario: Passive 31 | Given a successful login 32 | And the client has file "ascii_unix" 33 | And the client is in passive mode 34 | When the client successfully puts text "ascii_unix" 35 | Then the remote file "ascii_unix" should match the local file 36 | 37 | Scenario: Non-root working directory 38 | Given a successful login 39 | And the client has file "ascii_unix" 40 | And the server has directory "foo" 41 | And the client successfully cd's to "foo" 42 | When the client successfully puts text "ascii_unix" 43 | Then the remote file "foo/ascii_unix" should match the local file 44 | 45 | Scenario: Access denied 46 | Given a successful login 47 | And the client has file "forbidden" 48 | When the client puts text "forbidden" 49 | Then the server returns an access denied error 50 | 51 | Scenario: Missing directory 52 | Given a successful login 53 | And the client has file "bar" 54 | When the client puts text "foo/bar" 55 | Then the server returns a not found error 56 | 57 | Scenario: Not logged in 58 | Given a successful connection 59 | And the client has file "foo" 60 | When the client puts text "foo" 61 | Then the server returns a not logged in error 62 | 63 | Scenario: Missing path 64 | Given a successful login 65 | When the client puts with no path 66 | Then the server returns a syntax error 67 | 68 | Scenario: File system error 69 | Given a successful login 70 | And the client has file "unable" 71 | When the client puts text "unable" 72 | Then the server returns an action not taken error 73 | 74 | Scenario: Write not enabled 75 | Given the test server lacks write 76 | And a successful login 77 | And the client has file "foo" 78 | When the client puts text "foo" 79 | Then the server returns an unimplemented command error 80 | -------------------------------------------------------------------------------- /features/ftp_server/put_tls.feature: -------------------------------------------------------------------------------- 1 | Feature: Put TLS 2 | 3 | As a client 4 | I want to put a file securely 5 | So that nobody can intercept it 6 | 7 | Background: 8 | Given the test server has TLS mode "explicit" 9 | And the test server is started 10 | 11 | Scenario: Active 12 | Given a successful login with explicit TLS 13 | And the client has file "ascii_unix" 14 | And the client is in active mode 15 | When the client successfully puts text "ascii_unix" 16 | Then the remote file "ascii_unix" should match the local file 17 | 18 | Scenario: Passive 19 | Given a successful login with explicit TLS 20 | And the client has file "ascii_unix" 21 | And the client is in passive mode 22 | When the client successfully puts text "ascii_unix" 23 | Then the remote file "ascii_unix" should match the local file 24 | -------------------------------------------------------------------------------- /features/ftp_server/put_unique.feature: -------------------------------------------------------------------------------- 1 | Feature: Put Unique 2 | 3 | As a client 4 | I want to upload a file with a unique name 5 | So that it will not overwrite an existing file 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: File does not exist 11 | Given a successful login 12 | And the client has file "foo" 13 | When the client successfully stores unique "foo" 14 | Then the server should have a file with the contents of "foo" 15 | 16 | Scenario: Suggest name 17 | Given a successful login 18 | And the client has file "foo" 19 | When the client successfully stores unique "foo" to "bar" 20 | Then the server should have a file with the contents of "foo" 21 | And the server should have 1 file with "bar" in the name 22 | 23 | Scenario: Suggested name exists 24 | Given a successful login 25 | And the client has file "foo" 26 | And the server has file "bar" 27 | When the client successfully stores unique "foo" to "bar" 28 | Then the server should have a file with the contents of "foo" 29 | Then the server should have a file with the contents of "bar" 30 | And the server should have 2 files with "bar" in the name 31 | 32 | Scenario: Non-root working directory 33 | Given a successful login 34 | And the client has file "bar" 35 | And the server has directory "foo" 36 | And the client successfully cd's to "foo" 37 | When the client successfully stores unique "bar" to "bar" 38 | Then the remote file "foo/bar" should match the local file 39 | 40 | Scenario: Missing directory 41 | Given a successful login 42 | And the client has file "bar" 43 | When the client stores unique "bar" to "foo/bar" 44 | Then the server returns a not found error 45 | 46 | Scenario: Not logged in 47 | Given a successful connection 48 | When the client sends "STOU" 49 | Then the server returns a not logged in error 50 | 51 | Scenario: Write not enabled 52 | Given the test server lacks write 53 | And a successful login 54 | And the client has file "foo" 55 | When the client stores unique "foo" 56 | Then the server returns an unimplemented command error 57 | -------------------------------------------------------------------------------- /features/ftp_server/quit.feature: -------------------------------------------------------------------------------- 1 | Feature: Quit 2 | 3 | As a client 4 | In order to free up resources 5 | I want to close the connection 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Logged in 11 | Given a successful login 12 | When the client successfully quits 13 | Then the client should not be logged in 14 | 15 | Scenario: With a parameter 16 | Given a successful connection 17 | When the client quits with a parameter 18 | Then the server returns a syntax error 19 | 20 | Scenario: Not logged in 21 | Given a successful connection 22 | When the client quits 23 | Then the server returns a not logged in error 24 | -------------------------------------------------------------------------------- /features/ftp_server/reinitialize.feature: -------------------------------------------------------------------------------- 1 | Feature: Reinitialize 2 | 3 | As a client 4 | I want to know this command is not supported 5 | So that I can avoid using it 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Unimplemented 11 | Given a successful connection 12 | When the client sends command "REIN" 13 | Then the server returns an unimplemented command error 14 | -------------------------------------------------------------------------------- /features/ftp_server/rename.feature: -------------------------------------------------------------------------------- 1 | Feature: Rename 2 | 3 | As a client 4 | I want to rename a file 5 | To correct an improper name 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Rename 11 | Given a successful login 12 | And the server has file "foo" 13 | When the client successfully renames "foo" to "bar" 14 | Then the server should not have file "foo" 15 | And the server should have file "bar" 16 | 17 | Scenario: Move 18 | Given a successful login 19 | And the server has file "foo/bar" 20 | And the server has directory "baz" 21 | When the client successfully renames "foo/bar" to "baz/qux" 22 | Then the server should not have file "foo/bar" 23 | And the server should have file "baz/qux" 24 | 25 | Scenario: Non-root working directory 26 | Given a successful login 27 | And the server has file "foo/bar" 28 | And the client successfully cd's to "foo" 29 | When the client successfully renames "bar" to "baz" 30 | Then the server should not have file "foo/bar" 31 | Then the server should have file "foo/baz" 32 | 33 | Scenario: Access denied (source) 34 | Given a successful login 35 | When the client renames "forbidden" to "foo" 36 | Then the server returns an access denied error 37 | 38 | Scenario: Access denied (destination) 39 | Given a successful login 40 | And the server has file "foo" 41 | When the client renames "foo" to "forbidden" 42 | Then the server returns an access denied error 43 | 44 | Scenario: Source missing 45 | Given a successful login 46 | When the client renames "foo" to "bar" 47 | Then the server returns a not found error 48 | 49 | Scenario: Destination exists 50 | Given a successful login 51 | And the server has file "foo" 52 | And the server has file "bar" 53 | When the client renames "foo" to "bar" 54 | Then the server returns an already exists error 55 | 56 | Scenario: Not logged in (RNFR) 57 | Given a successful connection 58 | When the client sends "RNFR foo" 59 | Then the server returns a not logged in error 60 | 61 | Scenario: Not logged in (RNTO) 62 | Given a successful connection 63 | When the client sends "RNTO foo" 64 | # Although the RNTO command checks for logged in, sequence error 65 | # gets triggered first. 66 | Then the server returns a bad sequence error 67 | 68 | Scenario: Missing path (RNFR) 69 | Given a successful login 70 | When the client sends "RNFR" 71 | Then the server returns a syntax error 72 | 73 | Scenario: Missing path (RNTO) 74 | Given a successful login 75 | And the server has file "foo" 76 | And the client successfully sends "RNFR foo" 77 | When the client sends "RNTO" 78 | Then the server returns a syntax error 79 | 80 | Scenario: Rename not enabled 81 | Given the test server lacks rename 82 | And a successful login 83 | And the server has file "foo" 84 | When the client renames "foo" to "bar" 85 | Then the server returns an unimplemented command error 86 | 87 | Scenario: RNTO without RNFR 88 | Given a successful login 89 | When the client sends "RNTO bar" 90 | Then the server returns a bad sequence error 91 | 92 | Scenario: RNFR not followed by RNTO 93 | Given a successful login 94 | And the server has file "foo" 95 | And the client sends "RNFR foo" 96 | When the client sends "NOOP" 97 | Then the server returns a bad sequence error 98 | -------------------------------------------------------------------------------- /features/ftp_server/rmdir.feature: -------------------------------------------------------------------------------- 1 | Feature: Remove directory 2 | 3 | As a client 4 | I want to remove a directory 5 | To reduce clutter 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Make directory 11 | Given a successful login 12 | And the server has directory "foo" 13 | When the client successfully removes directory "foo" 14 | Then the server should not have directory "foo" 15 | 16 | Scenario: Directory of a directory 17 | Given a successful login 18 | And the server has directory "foo/bar" 19 | When the client successfully removes directory "foo/bar" 20 | Then the server should not have directory "foo/bar" 21 | And the server has directory "foo" 22 | 23 | Scenario: After cwd 24 | Given a successful login 25 | And the server has directory "foo/bar" 26 | And the client successfully cd's to "foo" 27 | When the client successfully removes directory "bar" 28 | Then the server should not have directory "foo/bar" 29 | 30 | Scenario: XRMD 31 | Given a successful login 32 | And the server has directory "foo" 33 | When the client successfully sends "XRMD foo" 34 | Then the server should not have directory "foo" 35 | 36 | Scenario: Missing directory 37 | Given a successful login 38 | When the client removes directory "foo/bar" 39 | Then the server returns a not found error 40 | 41 | Scenario: Not logged in 42 | Given a successful connection 43 | When the client removes directory "foo" 44 | Then the server returns a not logged in error 45 | 46 | Scenario: Does not exist 47 | Given a successful login 48 | When the client removes directory "foo" 49 | Then the server returns a not found error 50 | 51 | Scenario: Remove a file 52 | Given a successful login 53 | And the server has file "foo" 54 | When the client removes directory "foo" 55 | Then the server returns a not a directory error 56 | 57 | Scenario: Rmdir not enabled 58 | Given the test server lacks rmdir 59 | And a successful login 60 | When the client removes directory "foo" 61 | Then the server returns an unimplemented command error 62 | 63 | Scenario: Missing path 64 | Given a successful login 65 | When the client sends "RMD" 66 | Then the server returns a syntax error 67 | 68 | Scenario: Access denied 69 | Given a successful login 70 | When the client removes directory "forbidden" 71 | Then the server returns an access denied error 72 | -------------------------------------------------------------------------------- /features/ftp_server/site.feature: -------------------------------------------------------------------------------- 1 | Feature: Site 2 | 3 | As a client 4 | I want to know this command is not supported 5 | So that I can avoid using it 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Unimplemented 11 | Given a successful connection 12 | When the client sends command "SITE" 13 | Then the server returns an unimplemented command error 14 | -------------------------------------------------------------------------------- /features/ftp_server/size.feature: -------------------------------------------------------------------------------- 1 | Feature: Size 2 | 3 | As a client 4 | I want to know the size of a file 5 | So that I can tell how long it will take to get it 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: ASCII file with *nix line endings 11 | Given a successful login 12 | And the server has file "ascii_unix" 13 | When the client successfully gets size of text "ascii_unix" 14 | Then the reported size should be "83" 15 | 16 | Scenario: ASCII file with windows line endings 17 | Given a successful login 18 | And the server has file "ascii_windows" 19 | When the client successfully gets size of text "ascii_windows" 20 | Then the reported size should be "83" 21 | 22 | Scenario: Binary file 23 | Given a successful login 24 | And the server has file "binary" 25 | When the client successfully gets size of binary "binary" 26 | Then the reported size should be "256" 27 | 28 | Scenario: File in subdirectory 29 | Given a successful login 30 | And the server has file "foo/ascii_unix" 31 | Then the client successfully gets size of text "foo/ascii_unix" 32 | 33 | Scenario: Non-root working directory 34 | Given a successful login 35 | And the server has file "foo/ascii_unix" 36 | And the client successfully cd's to "foo" 37 | Then the client successfully gets size of text "ascii_unix" 38 | 39 | Scenario: Access denied 40 | Given a successful login 41 | When the client gets size of text "forbidden" 42 | Then the server returns an access denied error 43 | 44 | Scenario: Missing file 45 | Given a successful login 46 | When the client gets size of text "foo" 47 | Then the server returns a not found error 48 | 49 | Scenario: Not logged in 50 | Given a successful connection 51 | When the client gets size of text "foo" 52 | Then the server returns a not logged in error 53 | 54 | Scenario: Missing path 55 | Given a successful login 56 | When the client gets size with no path 57 | Then the server returns a syntax error 58 | 59 | Scenario: File system error 60 | Given a successful login 61 | When the client gets size of text "unable" 62 | Then the server returns an action not taken error 63 | 64 | Scenario: Read not enabled 65 | Given the test server lacks read 66 | And a successful login 67 | And the server has file "foo" 68 | When the client gets size of text "foo" 69 | Then the server returns an unimplemented command error 70 | -------------------------------------------------------------------------------- /features/ftp_server/status.feature: -------------------------------------------------------------------------------- 1 | Feature: Status 2 | 3 | As a client 4 | I want server status 5 | To know what state the server is in 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Not logged in 11 | Given a successful connection 12 | When the client requests status 13 | Then the server returns a not logged in error 14 | 15 | Scenario: Server status 16 | Given a successful login 17 | When the client successfully requests status 18 | Then the server returns its title 19 | -------------------------------------------------------------------------------- /features/ftp_server/step_definitions/logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Then /^the server should have written( no)? log output$/ do |neg| 4 | verb = if neg 5 | :to 6 | else 7 | :to_not 8 | end 9 | expect(server.log_output).send(verb, eq('')) 10 | end 11 | -------------------------------------------------------------------------------- /features/ftp_server/step_definitions/test_server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | def server 4 | @server ||= TestServer.new 5 | end 6 | 7 | Given /^the test server is started$/ do 8 | server.start 9 | end 10 | 11 | Given /^the test server has TLS mode "(\w+)"$/ do |mode| 12 | server.tls = mode.to_sym 13 | end 14 | 15 | Given(/^the test server is bound to "(.*?)"$/) do |ip_address| 16 | server.interface = ip_address 17 | end 18 | 19 | Given /^the test server has logging (enabled|disabled)$/ do |state| 20 | server.logging = state == 'enabled' 21 | end 22 | 23 | Given /^the test server lacks (\w+)$/ do |feature| 24 | server.send "#{feature}=", false 25 | end 26 | 27 | Given /^the test server has auth level "(.*?)"$/ do |auth_level| 28 | auth_level = Ftpd.const_get(auth_level) 29 | server.auth_level = auth_level 30 | end 31 | 32 | Given /^the test server has session timeout set to (\S+) seconds$/ do 33 | |timeout| 34 | server.session_timeout = timeout.to_f 35 | end 36 | 37 | Given /^the test server has session timeout disabled$/ do 38 | server.session_timeout = nil 39 | end 40 | 41 | Given /^the test server disallows low data ports$/ do 42 | server.allow_low_data_ports = false 43 | end 44 | 45 | Given /^the test server allows low data ports$/ do 46 | server.allow_low_data_ports = true 47 | end 48 | 49 | Given /^the test server has max_connections set to (\d+)$/ do |s| 50 | server.max_connections = s.to_i 51 | end 52 | 53 | Given /^the test server has max_connections_per_ip set to (\d+)$/ do |s| 54 | server.max_connections_per_ip = s.to_i 55 | end 56 | 57 | Given /^the test server has no max failed login attempts$/ do 58 | server.max_failed_logins = nil 59 | end 60 | 61 | Given /^the test server has a max of (\d+) failed login attempts$/ do |s| 62 | server.max_failed_logins = s.to_i 63 | end 64 | 65 | Given /^the test server has a failed login delay of (\S+) seconds$/ do |s| 66 | server.failed_login_delay = s.to_f 67 | end 68 | 69 | Given /^the test server has a NAT IP of (\S+)$/ do |s| 70 | server.nat_ip = s 71 | end 72 | -------------------------------------------------------------------------------- /features/ftp_server/structure_mount.feature: -------------------------------------------------------------------------------- 1 | Feature: Structure Mount 2 | 3 | As a client 4 | I want to know this command is not supported 5 | So that I can avoid using it 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Unimplemented 11 | Given a successful connection 12 | When the client sends command "SMNT" 13 | Then the server returns an unimplemented command error 14 | -------------------------------------------------------------------------------- /features/ftp_server/syntax_errors.feature: -------------------------------------------------------------------------------- 1 | Feature: Syntax Errors 2 | 3 | As a client 4 | I want good error messages 5 | So that I can figure out what went wrong 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Empty command 11 | Given a successful connection 12 | When the client sends an empty command 13 | Then the server returns a syntax error 14 | 15 | Scenario: Command contains non-word characters 16 | Given a successful connection 17 | When the client sends a non-word command 18 | Then the server returns a syntax error 19 | -------------------------------------------------------------------------------- /features/ftp_server/syst.feature: -------------------------------------------------------------------------------- 1 | Feature: Port 2 | 3 | As a client 4 | I want to identify the server 5 | So that I know how it will behave 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: Success 11 | Given a successful connection 12 | When the client successfully queries system ID 13 | Then the server returns a system ID reply 14 | 15 | Scenario: With argument 16 | Given a successful login 17 | When the client sends "SYST 1" 18 | Then the server returns a syntax error 19 | -------------------------------------------------------------------------------- /features/ftp_server/timeout.feature: -------------------------------------------------------------------------------- 1 | Feature: Port 2 | 3 | As a programmer 4 | I want idle sessions to timeout and disconnect 5 | So that I can claim RFC compliance 6 | 7 | Scenario: Session idle too long 8 | Given the test server has session timeout set to 0.5 seconds 9 | And the test server is started 10 | And a successful login 11 | When the client is idle for 0.6 seconds 12 | Then the client should not be connected 13 | 14 | Scenario: Session not idle too long 15 | Given the test server has session timeout set to 0.5 seconds 16 | And the test server is started 17 | And a successful login 18 | When the client is idle for 0 seconds 19 | Then the client should be connected 20 | 21 | Scenario: Timeout disabled 22 | Given the test server has session timeout disabled 23 | And the test server is started 24 | And a successful login 25 | When the client is idle for 0.6 seconds 26 | Then the client should be connected 27 | -------------------------------------------------------------------------------- /features/ftp_server/type.feature: -------------------------------------------------------------------------------- 1 | Feature: Representation Type 2 | 3 | As a client 4 | I want to set the representation type 5 | So that I can interoperate with foreign operating systems 6 | 7 | Background: 8 | Given the test server is started 9 | 10 | Scenario: ASCII/default 11 | Given a successful login 12 | Then the client successfully sets type "A" 13 | 14 | Scenario: ASCII/Non-print 15 | Given a successful login 16 | Then the client successfully sets type "A N" 17 | 18 | Scenario: ASCII/Telnet 19 | Given a successful login 20 | When the client successfully sets type "A T" 21 | 22 | Scenario: Type IMAGE 23 | Given a successful login 24 | Then the client successfully sets type "I" 25 | 26 | Scenario: Type EBCDIC 27 | Given a successful login 28 | When the client sets type "E" 29 | Then the server returns a type not implemented error 30 | 31 | Scenario: Type Local 32 | Given a successful login 33 | When the client sets type "L 7" 34 | Then the server returns a type not implemented error 35 | 36 | Scenario: Invalid Type 37 | Given a successful login 38 | When the client sets type "*" 39 | Then the server returns an invalid type error 40 | 41 | Scenario: Format Carriage Control 42 | Given a successful login 43 | When the client sets type "A C" 44 | Then the server returns a type not implemented error 45 | 46 | Scenario: Invalid Format 47 | Given a successful login 48 | When the client sets type "A *" 49 | Then the server returns an invalid type error 50 | 51 | Scenario: Not logged in 52 | Given a successful connection 53 | When the client sets type "S" 54 | Then the server returns a not logged in error 55 | 56 | Scenario: Missing parameter 57 | Given a successful login 58 | When the client sets type with no parameter 59 | Then the server returns a syntax error 60 | -------------------------------------------------------------------------------- /features/step_definitions/append.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client appends (.*)$/ do |args| 4 | capture_error do 5 | step "the client successfully appends #{args}" 6 | end 7 | end 8 | 9 | When /^the client successfully appends text "(.*?)" onto "(.*?)"$/ do 10 | |local_path, remote_path| 11 | client.append_text local_path, remote_path 12 | end 13 | 14 | When /^the client successfully appends binary "(.*?)" onto "(.*?)"$/ do 15 | |local_path, remote_path| 16 | client.append_binary local_path, remote_path 17 | end 18 | -------------------------------------------------------------------------------- /features/step_definitions/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'singleton' 4 | 5 | class Clients 6 | 7 | include Singleton 8 | 9 | def initialize 10 | @clients = {} 11 | end 12 | 13 | def [](client_name) 14 | @clients[client_name] ||= TestClient.new 15 | end 16 | 17 | def close 18 | @clients.values.each(&:close) 19 | end 20 | 21 | end 22 | 23 | def client(client_name = nil) 24 | client_name ||= 'client' 25 | client_name = client_name.strip 26 | Clients.instance[client_name] 27 | end 28 | -------------------------------------------------------------------------------- /features/step_definitions/client_and_server_files.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | def unix_line_endings(exactly, s) 4 | return s if exactly 5 | s.gsub(/\r\n/, "\n") 6 | end 7 | 8 | Then /^the remote file "(.*?)" should( exactly)? match the local file$/ do 9 | |remote_path, exactly| 10 | local_path = File.basename(remote_path) 11 | remote_contents = server.file_contents(remote_path) 12 | local_contents = client.file_contents(local_path) 13 | remote_contents = unix_line_endings(exactly, remote_contents) 14 | local_contents = unix_line_endings(exactly, local_contents) 15 | expect(remote_contents).to eq local_contents 16 | end 17 | 18 | Then /^the local file "(.*?)" should( exactly)? match the remote file$/ do 19 | |local_path, exactly| 20 | remote_path = local_path 21 | remote_contents = server.file_contents(remote_path) 22 | local_contents = client.file_contents(local_path) 23 | remote_contents = unix_line_endings(exactly, remote_contents) 24 | local_contents = unix_line_endings(exactly, local_contents) 25 | expect(local_contents).to eq remote_contents 26 | end 27 | -------------------------------------------------------------------------------- /features/step_definitions/client_files.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Given /^the client has file "(.*?)"$/ do |local_path| 4 | client.add_file local_path 5 | end 6 | 7 | Then /^the local file "(.*?)" should have (unix|windows) line endings$/ do 8 | |local_path, line_ending_type| 9 | expect(line_ending_type(client.file_contents(local_path))).to eq \ 10 | line_ending_type.to_sym 11 | end 12 | 13 | Then /^the local file "(.*?)" should match its template$/ do |local_path| 14 | expect(client.template(local_path)).to eq \ 15 | client.file_contents(local_path) 16 | end 17 | -------------------------------------------------------------------------------- /features/step_definitions/command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client sends command "(.*?)"$/ do |command| 4 | capture_error do 5 | client.raw command 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /features/step_definitions/connect.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'double_bag_ftps' 4 | require 'net/ftp' 5 | 6 | When /^the( \w+)? client connects(?: with (\w+) TLS)?$/ do 7 | |client_name, tls_mode| 8 | begin 9 | tls_mode ||= 'off' 10 | c = client(client_name) 11 | c.tls_mode = tls_mode.to_sym 12 | c.start 13 | c.connect(server.host, server.port) 14 | rescue TestClient::CannotTestTls => e 15 | pending(e.message) 16 | end 17 | end 18 | 19 | When /^the (\d+)rd client tries to connect$/ do |client_name| 20 | client(client_name).start 21 | capture_error do 22 | client(client_name).connect(server.host, server.port) 23 | end 24 | end 25 | 26 | When /^the (\S+) client connects from (\S+)$/ do 27 | |client_name, source_ip| 28 | client(client_name).connect_from(source_ip, server.host, server.port) 29 | end 30 | 31 | When /^the (\S+) client tries to connect from (\S+)$/ do 32 | |client_name, source_ip| 33 | capture_error do 34 | step "the #{client_name} client connects from #{source_ip}" 35 | end 36 | end 37 | 38 | Then /^the client should be connected$/ do 39 | expect(client).to be_connected 40 | end 41 | 42 | Then /^the client should not be connected$/ do 43 | expect(client).to_not be_connected 44 | end 45 | -------------------------------------------------------------------------------- /features/step_definitions/delete.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client deletes "(.*?)"$/ do |path| 4 | capture_error do 5 | step %Q(the client successfully deletes "#{path}") 6 | end 7 | end 8 | 9 | When /^the client successfully deletes "(.*?)"$/ do |path| 10 | client.delete path 11 | end 12 | 13 | When /^the client deletes with no path$/ do 14 | capture_error do 15 | client.raw 'DELE' 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /features/step_definitions/directory_navigation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client cd's to "(.*?)"$/ do |path| 4 | capture_error do 5 | step %Q(the client successfully cd's to "#{path}") 6 | end 7 | end 8 | 9 | # As of Ruby 1.9.3-p125, Net::FTP#chdir('..') will send a CDUP. 10 | # However, that could conceivably change: The use of CDUP not 11 | # required by the FTP protocol. Therefore we use this step to 12 | # ensure that CDUP is sent and therefore tested. 13 | 14 | When /^the client successfully cd's up$/ do 15 | client.raw 'CDUP' 16 | end 17 | 18 | When /^the client successfully cd's to "(.*?)"$/ do |path| 19 | client.chdir path 20 | end 21 | 22 | Then /^the current directory should be "(.*?)"$/ do |path| 23 | expect(client.pwd).to eq path 24 | end 25 | 26 | Then /^the XPWD directory should be "(.*?)"$/ do |path| 27 | expect(client.xpwd).to eq path 28 | end 29 | -------------------------------------------------------------------------------- /features/step_definitions/features.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client successfully requests features$/ do 4 | @feature_reply = client.raw "FEAT" 5 | end 6 | 7 | def feature_regexp(feature) 8 | /^ #{feature}$/ 9 | end 10 | 11 | Then /^the response should include feature "(.*?)"$/ do |feature| 12 | expect(@feature_reply).to match feature_regexp(feature) 13 | end 14 | 15 | Then /^the response should not include feature "(.*?)"$/ do |feature| 16 | expect(@feature_reply).to_not match feature_regexp(feature) 17 | end 18 | 19 | Then /^the response should( not)? include TLS features$/ do |neg| 20 | step %Q'the response should#{neg} include feature "AUTH TLS"' 21 | step %Q'the response should#{neg} include feature "PBSZ"' 22 | step %Q'the response should#{neg} include feature "PROT"' 23 | end 24 | -------------------------------------------------------------------------------- /features/step_definitions/file_structure.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client successfully sets file structure "(.*?)"$/ do 4 | |file_structure| 5 | client.raw 'STRU', file_structure 6 | end 7 | 8 | When /^the client sets file structure "(.*?)"$/ do |file_structure| 9 | capture_error do 10 | step %Q'the client successfully sets file structure "#{file_structure}"' 11 | end 12 | end 13 | 14 | When /^the client sets file structure with no parameter$/ do 15 | capture_error do 16 | client.raw 'STRU' 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /features/step_definitions/generic_send.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client successfully sends "(.*?)"$/ do |command| 4 | @reply = client.raw command 5 | end 6 | 7 | When /^the client sends "(.*?)"$/ do |command| 8 | capture_error do 9 | step %Q'the client successfully sends "#{command}"' 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /features/step_definitions/get.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client successfully gets (text|binary) "(.*?)"$/ \ 4 | do |mode, remote_path| 5 | client.get mode, remote_path 6 | end 7 | 8 | When /^the client gets (\S+) "(.*?)"$/ do |mode, path| 9 | capture_error do 10 | step %Q(the client successfully gets #{mode} "#{path}") 11 | end 12 | end 13 | 14 | When /^the client gets with no path$/ do 15 | capture_error do 16 | client.raw 'RETR' 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /features/step_definitions/help.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client successfully asks for help(?: for "(.*?)")?$/ do 4 | |command| 5 | @help_reply = client.help(command) 6 | end 7 | 8 | Then /^the server should return a list of commands$/ do 9 | commands = @help_reply.scan(/\b([A-Z][A-Z]+)\b/).flatten 10 | expect(commands).to include 'NOOP' 11 | expect(commands).to include 'USER' 12 | end 13 | 14 | Then /^the server should return help for "(.*?)"$/ do |command| 15 | expect(@help_reply).to match /Command #{command} is recognized/ 16 | end 17 | 18 | Then /^the server should return no help for "(.*?)"$/ do |command| 19 | expect(@help_reply).to match /Command #{command} is not recognized/ 20 | end 21 | -------------------------------------------------------------------------------- /features/step_definitions/invalid_commands.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client sends an empty command$/ do 4 | capture_error do 5 | client.raw '' 6 | end 7 | end 8 | 9 | When /^the client sends a non-word command$/ do 10 | capture_error do 11 | client.raw '*' 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /features/step_definitions/ipv6.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../testlib/network" 4 | 5 | include TestLib::Network 6 | 7 | Given /^the stack supports ipv6$/ do 8 | unless ipv6_supported? 9 | pending "Test skipped: stack does not support ipv6" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /features/step_definitions/line_endings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | def line_ending_type(s) 4 | if s =~ /\r\n/ 5 | :windows 6 | else 7 | :unix 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /features/step_definitions/list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class FileList 4 | 5 | def initialize(lines) 6 | @lines = lines 7 | end 8 | 9 | def filenames 10 | @lines.map(&:split).map(&:last) 11 | end 12 | 13 | def long_form? 14 | @lines.all? do |line| 15 | line =~ /^[rwxSst-]{10}/ 16 | end 17 | end 18 | 19 | def short_form? 20 | !long_form? 21 | end 22 | 23 | def eplf_format? 24 | @lines.all? do |line| 25 | line =~ /^\+.*\t.*$/ 26 | end 27 | end 28 | 29 | def empty? 30 | @lines.empty? 31 | end 32 | 33 | end 34 | 35 | When /^the client successfully lists the directory(?: "(.*?)")?$/ do |directory| 36 | @list = FileList.new(client.ls(*[directory].compact)) 37 | end 38 | 39 | When /^the client lists the directory( "(?:.*?)")?$/ do |directory| 40 | capture_error do 41 | step "the client successfully lists the directory#{directory}" 42 | end 43 | end 44 | 45 | When /^the client successfully name-lists the directory(?: "(.*?)")?$/ do 46 | |directory| 47 | @list = FileList.new(client.nlst(*[directory].compact)) 48 | end 49 | 50 | When /^the client name-lists the directory( "(?:.*?)")?$/ do |directory| 51 | capture_error do 52 | step "the client successfully name-lists the directory#{directory}" 53 | end 54 | end 55 | 56 | Then /^the file list should( not)? contain "(.*?)"$/ do |neg, filename| 57 | verb = if neg 58 | :to_not 59 | else 60 | :to 61 | end 62 | expect(@list.filenames).send(verb, include(filename)) 63 | end 64 | 65 | Then /^the file list should be in (long|short) form$/ do |form| 66 | expect(@list).to send("be_#{form}_form") 67 | end 68 | 69 | Then /^the file list should be empty$/ do 70 | expect(@list).to be_empty 71 | end 72 | 73 | Then /^the list should be in EPLF format$/ do 74 | expect(@list).to be_eplf_format 75 | end 76 | -------------------------------------------------------------------------------- /features/step_definitions/login.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | def logged_in? 4 | step "the client lists the directory" 5 | @error.nil? 6 | end 7 | 8 | def login(tokens, client_name = nil) 9 | capture_error do 10 | client(client_name).login *tokens 11 | end 12 | end 13 | 14 | Given /^a successful connection( with \w+ TLS)?$/ do |with_tls| 15 | step "the client connects#{with_tls}" 16 | end 17 | 18 | Given /^a successful login( with \w+ TLS)?$/ do |with_tls| 19 | step "a successful connection#{with_tls}" 20 | step 'the client logs in' 21 | end 22 | 23 | Given /^a failed login$/ do 24 | step 'the client connects' 25 | step 'the client logs in with bad user' 26 | end 27 | 28 | When /^the(?: (\w+))? client logs in(?: with bad (\w+))?$/ do 29 | |client_name, bad| 30 | tokens = [ 31 | if bad == 'user' 32 | 'bad_user' 33 | else 34 | @server.user 35 | end, 36 | if bad == 'password' 37 | 'bad_password' 38 | else 39 | @server.password 40 | end, 41 | if bad == 'account' 42 | 'bad_account' 43 | else 44 | @server.account 45 | end, 46 | ][0..server.auth_level] 47 | @error = login(tokens, client_name) 48 | end 49 | 50 | Then /^the client should( not)? be logged in$/ do |neg| 51 | matcher_method = if neg 52 | :be_falsey 53 | else 54 | :be_truthy 55 | end 56 | expect(logged_in?).to send(matcher_method) 57 | end 58 | 59 | When /^the client sends a password( with no parameter)?$/ do |no_param| 60 | capture_error do 61 | args = if no_param 62 | [] 63 | else 64 | [server.password] 65 | end 66 | client.raw 'PASS', *args 67 | end 68 | end 69 | 70 | When /^the client sends a user( with no parameter)?$/ do |no_param| 71 | capture_error do 72 | args = if no_param 73 | [] 74 | else 75 | [server.user] 76 | end 77 | client.raw 'USER', *args 78 | end 79 | end 80 | 81 | Given /^the (\w+) client connects and logs in$/ do |client_name| 82 | step "the #{client_name} client connects" 83 | step "the #{client_name} client logs in" 84 | end 85 | -------------------------------------------------------------------------------- /features/step_definitions/mkdir.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client makes directory "(.*?)"$/ do |path| 4 | capture_error do 5 | step %Q(the client successfully makes directory "#{path}") 6 | end 7 | end 8 | 9 | When /^the client successfully makes directory "(.*?)"$/ do |path| 10 | mkdir_response = client.mkdir path 11 | end 12 | -------------------------------------------------------------------------------- /features/step_definitions/mode.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client successfully sets mode "(.*?)"$/ do |mode| 4 | client.raw 'MODE', mode 5 | end 6 | 7 | When /^the client sets mode "(.*?)"$/ do |mode| 8 | capture_error do 9 | step %Q'the client successfully sets mode "#{mode}"' 10 | end 11 | end 12 | 13 | When /^the client sets mode with no parameter$/ do 14 | capture_error do 15 | client.raw 'MODE' 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /features/step_definitions/mtime.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'date' 4 | 5 | When /^the client successfully gets mtime of "(.*?)"$/ \ 6 | do |remote_path| 7 | @mtime = client.get_mtime remote_path 8 | end 9 | 10 | When /^the client gets mtime of "(.*?)"$/ do |path| 11 | capture_error do 12 | step %Q(the client successfully gets mtime of "#{path}") 13 | end 14 | end 15 | 16 | When /^the client gets mtime with no path$/ do 17 | capture_error do 18 | client.raw 'MDTM' 19 | end 20 | end 21 | 22 | Then(/^the reported mtime should be "(.*?)"$/) do |mtime| 23 | expected_time = DateTime.parse(mtime).to_time.utc 24 | expect(@mtime).to eq expected_time 25 | end 26 | -------------------------------------------------------------------------------- /features/step_definitions/noop.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the( \w+)? client successfully does nothing( with a parameter)?$/ do 4 | |client_name, with_param| 5 | if with_param 6 | client(client_name).raw 'NOOP', 'foo' 7 | else 8 | client(client_name).noop 9 | end 10 | end 11 | 12 | When /^the( \w+)? client does nothing( with a parameter)?$/ do 13 | |client_name, with_param| 14 | capture_error do 15 | step "the#{client_name} client successfully does nothing#{with_param}" 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /features/step_definitions/options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client successfully sets option "(.*?)"$/ do |option| 4 | client.set_option option 5 | end 6 | 7 | When /^the client sets option "(.*?)"$/ do |option| 8 | capture_error do 9 | step %q'the client successfully sets option "#{option}"' 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /features/step_definitions/passive.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Given /^the client is in (passive|active) mode$/ do |mode| 4 | client.passive = mode == 'passive' 5 | end 6 | 7 | Then(/^the server advertises passive IP (\S+)$/) do |ip| 8 | quads = @reply.scan(/\d+/)[1..4].join(".") 9 | expect(quads).to eq ip 10 | end 11 | -------------------------------------------------------------------------------- /features/step_definitions/pending.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Given /^PENDING/ do 4 | pending 5 | end 6 | -------------------------------------------------------------------------------- /features/step_definitions/port.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client sends PORT "(.*?)"$/ do |param| 4 | capture_error do 5 | client.raw 'PORT', param 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /features/step_definitions/put.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client successfully puts (text|binary) "(.*?)"$/ do 4 | |mode, local_path| 5 | client.put mode, local_path 6 | end 7 | 8 | When /^the client puts (\S+) "(.*?)"$/ do |mode, path| 9 | capture_error do 10 | step %Q(the client successfully puts #{mode} "#{path}") 11 | end 12 | end 13 | 14 | When /^the client puts with no path$/ do 15 | capture_error do 16 | client.raw 'STOR' 17 | end 18 | end 19 | 20 | When /^the client successfully stores unique "(.*?)"(?: to "(.*?)")?$/ do 21 | |local_path, remote_path| 22 | client.store_unique local_path, remote_path 23 | end 24 | 25 | When /^the client stores unique "(.*?)"( to ".*?")?$/ do 26 | |local_path, remote_path| 27 | capture_error do 28 | step(%Q'the client successfully stores unique ' + 29 | %Q'"#{local_path}"#{remote_path}') 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /features/step_definitions/quit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client successfully quits$/ do 4 | client.quit 5 | end 6 | 7 | When /^the client quits$/ do 8 | capture_error do 9 | step 'the client successfully quits' 10 | end 11 | end 12 | 13 | When /^the client quits with a parameter$/ do 14 | capture_error do 15 | client.raw 'QUIT', 'foo' 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /features/step_definitions/rename.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client renames "(.*?)" to "(.*?)"$/ do 4 | |from_path, to_path| 5 | capture_error do 6 | step %Q'the client successfully renames "#{from_path}" to "#{to_path}"' 7 | end 8 | end 9 | 10 | When /^the client successfully renames "(.*?)" to "(.*?)"$/ do 11 | |from_path, to_path| 12 | client.rename(from_path, to_path) 13 | end 14 | -------------------------------------------------------------------------------- /features/step_definitions/rmdir.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client removes directory "(.*?)"$/ do |path| 4 | capture_error do 5 | step %Q(the client successfully removes directory "#{path}") 6 | end 7 | end 8 | 9 | When /^the client successfully removes directory "(.*?)"$/ do |path| 10 | mkdir_response = client.rmdir path 11 | end 12 | -------------------------------------------------------------------------------- /features/step_definitions/server_files.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'date' 4 | 5 | Given /^the server has directory "(.*?)"$/ do |remote_path| 6 | server.add_directory remote_path 7 | end 8 | 9 | Given /^the server has file "(.*?)"$/ do |remote_path| 10 | server.add_file remote_path 11 | end 12 | 13 | Given(/^the file "(.*?)" has mtime "(.*?)"$/) do |remote_path, mtime| 14 | mtime = DateTime.parse(mtime).to_time.utc 15 | server.set_mtime remote_path, mtime 16 | end 17 | 18 | Then /^the server should( not)? have file "(.*?)"$/ do |neg, path| 19 | matcher = if neg 20 | :be_falsey 21 | else 22 | :be_truthy 23 | end 24 | expect(server.has_file?(path)).to send(matcher) 25 | end 26 | 27 | Then /^the server should( not)? have directory "(.*?)"$/ do |neg, path| 28 | matcher = if neg 29 | :be_falsey 30 | else 31 | :be_truthy 32 | end 33 | expect(server.has_directory?(path)).to send(matcher) 34 | end 35 | 36 | Then /^the remote file "(.*?)" should have (unix|windows) line endings$/ do 37 | |remote_path, line_ending_type| 38 | expect(line_ending_type(server.file_contents(remote_path))).to eq \ 39 | line_ending_type.to_sym 40 | end 41 | 42 | Then /^the server should have a file with the contents of "(.*?)"$/ do 43 | |path| 44 | expect(server.has_file_with_contents_of?(path)).to be_truthy 45 | end 46 | 47 | Then /^the server should have (\d+) files? with "(.*?)" in the name$/ do 48 | |count, name| 49 | expect(server.files_named_like(name).size).to eq count.to_i 50 | end 51 | 52 | Then /^the remote file "(.*?)" should match "(\w+)" \+ "(\w+)"$/ do 53 | |remote_path, template1, template2| 54 | expected = @server.template(template1) + @server.template(template2) 55 | actual = @server.file_contents(remote_path) 56 | expect(actual).to eq expected 57 | end 58 | 59 | Then /^the remote file "(.*?)" should match "(\w+)"$/ do |remote_path, template| 60 | expected = @server.template(template) 61 | actual = @server.file_contents(remote_path) 62 | expect(actual).to eq expected 63 | end 64 | -------------------------------------------------------------------------------- /features/step_definitions/server_title.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Then /^the server returns its title$/ do 4 | step 'the server returns its name' 5 | step 'the server returns its version' 6 | end 7 | 8 | Then /^the server returns its name$/ do 9 | expect(@response).to include @server.server_name 10 | end 11 | 12 | Then /^the server returns its version$/ do 13 | expect(@response).to match /\b\d+\.\d+\.\d+\b/ 14 | end 15 | -------------------------------------------------------------------------------- /features/step_definitions/size.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client successfully gets size of (text|binary) "(.*?)"$/ \ 4 | do |mode, remote_path| 5 | @size = client.get_size mode, remote_path 6 | end 7 | 8 | When /^the client gets size of (\S+) "(.*?)"$/ do |mode, path| 9 | capture_error do 10 | step %Q(the client successfully gets size of #{mode} "#{path}") 11 | end 12 | end 13 | 14 | When /^the client gets size with no path$/ do 15 | capture_error do 16 | client.raw 'SIZE' 17 | end 18 | end 19 | 20 | Then(/^the reported size should be "(.*?)"$/) do |size| 21 | expect(@size).to eq size.to_i 22 | end 23 | -------------------------------------------------------------------------------- /features/step_definitions/status.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client successfully requests status$/ do 4 | @response = client.status 5 | end 6 | 7 | When /^the client requests status$/ do 8 | capture_error do 9 | step 'the client successfully requests status' 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /features/step_definitions/success_replies.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Then /^the server returns a "(.*?)" reply$/ do |reply| 4 | expect(@reply).to eq reply + "\n" 5 | end 6 | 7 | Then /^the server returns a not necessary reply$/ do 8 | step 'the server returns a "202 Command not needed at this site" reply' 9 | end 10 | -------------------------------------------------------------------------------- /features/step_definitions/system.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client successfully queries system ID$/ do 4 | @reply = client.system 5 | # Prior to ruby-2.3.0, the #system call returned a string ending in 6 | # a new-line. 7 | @reply += "\n" unless @reply =~ /\n$/ 8 | end 9 | 10 | Then /^the server returns a system ID reply$/ do 11 | step 'the server returns a "UNIX Type: L8" reply' 12 | end 13 | -------------------------------------------------------------------------------- /features/step_definitions/timing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Before do 4 | @start_time = Time.now 5 | end 6 | 7 | When /^the client is idle for (\S+) seconds$/ do |seconds| 8 | sleep seconds.to_f 9 | end 10 | 11 | Then /^it should take at least (\S+) seconds$/ do |s| 12 | min_elapsed_time = s.to_f 13 | elapsed_time = Time.now - @start_time 14 | expect(elapsed_time).to be >= min_elapsed_time 15 | end 16 | 17 | Then /^it should take less than (\S+) seconds$/ do |s| 18 | max_elapsed_time = s.to_f 19 | elapsed_time = Time.now - @start_time 20 | expect(elapsed_time).to be < max_elapsed_time 21 | end 22 | -------------------------------------------------------------------------------- /features/step_definitions/type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When /^the client successfully sets type "(.*?)"$/ do |type| 4 | client.raw 'TYPE', type 5 | end 6 | 7 | When /^the client sets type "(.*?)"$/ do |type| 8 | capture_error do 9 | step %Q'the client successfully sets type "#{type}"' 10 | end 11 | end 12 | 13 | When /^the client sets type with no parameter$/ do 14 | capture_error do 15 | client.raw 'TYPE' 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /features/support/env.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH << File.expand_path('../../lib', File.dirname(__FILE__)) 4 | 5 | require 'ftpd' 6 | require 'stringio' 7 | -------------------------------------------------------------------------------- /features/support/example_server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fileutils' 4 | require 'forwardable' 5 | require File.expand_path('test_server_files', 6 | File.dirname(__FILE__)) 7 | 8 | class ExampleServer 9 | 10 | extend Forwardable 11 | include FileUtils 12 | include TestServerFiles 13 | 14 | def initialize(args = nil) 15 | command = [ 16 | File.expand_path('../../examples/example.rb', 17 | File.dirname(__FILE__)), 18 | args, 19 | ].join(' ') 20 | @io = IO.popen(command, 'r+') 21 | @output = read_output 22 | end 23 | 24 | def stop 25 | @io.close 26 | end 27 | 28 | def host 29 | @output[/Host: (.*)$/, 1] 30 | end 31 | 32 | def port 33 | @output[/Port: (.*)$/, 1].to_i 34 | end 35 | 36 | def user 37 | @output[/User: "(.*)"$/, 1] 38 | end 39 | 40 | def password 41 | @output[/Pass: "(.*)"$/, 1] 42 | end 43 | 44 | def account 45 | @output[/Account: "(.*)"$/, 1] 46 | end 47 | 48 | def auth_level 49 | Ftpd::AUTH_PASSWORD 50 | end 51 | 52 | private 53 | 54 | def read_output 55 | output = ''.dup 56 | loop do 57 | line = @io.gets 58 | break if line.nil? 59 | output << line 60 | break if line =~ /FTP server started/ 61 | end 62 | output 63 | end 64 | 65 | def temp_dir 66 | @output[/Directory: (.*)$/, 1] 67 | end 68 | 69 | end 70 | -------------------------------------------------------------------------------- /features/support/file_templates/ascii_unix: -------------------------------------------------------------------------------- 1 | Billy was a chemist 2 | but Billy is no more 3 | For what he thought was H2O 4 | Was H2SO4 5 | -------------------------------------------------------------------------------- /features/support/file_templates/ascii_windows: -------------------------------------------------------------------------------- 1 | Billy was a chemist 2 | but Billy is no more 3 | For what he thought was H2O 4 | Was H2SO4 5 | -------------------------------------------------------------------------------- /features/support/file_templates/binary: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wconrad/ftpd/4540a290c91e4b8de287ef03b0c01dc04f9ec5d6/features/support/file_templates/binary -------------------------------------------------------------------------------- /features/support/test_file_templates.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TestFileTemplates 4 | 5 | def [](filename) 6 | if have_template?(filename) 7 | read_template filename 8 | else 9 | default_template filename 10 | end 11 | end 12 | 13 | private 14 | 15 | def have_template?(filename) 16 | File.exist?(template_path(filename)) 17 | end 18 | 19 | def read_template(filename) 20 | File.open(template_path(filename), 'rb', &:read) 21 | end 22 | 23 | def template_path(filename) 24 | File.expand_path(filename, templates_path) 25 | end 26 | 27 | def templates_path 28 | File.expand_path('file_templates', File.dirname(__FILE__)) 29 | end 30 | 31 | def default_template(filename) 32 | "Contents of #{filename}" 33 | end 34 | 35 | end 36 | -------------------------------------------------------------------------------- /features/support/test_server_files.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TestServerFiles 4 | 5 | def add_file(path) 6 | full_path = temp_path(path) 7 | mkdir_p File.dirname(full_path) 8 | File.open(full_path, 'wb') do |file| 9 | file.write @templates[File.basename(full_path)] 10 | end 11 | end 12 | 13 | def set_mtime(path, mtime) 14 | full_path = temp_path(path) 15 | File.utime(File.atime(full_path), mtime, full_path) 16 | end 17 | 18 | def add_directory(path) 19 | full_path = temp_path(path) 20 | mkdir_p full_path 21 | end 22 | 23 | def has_file?(path) 24 | full_path = temp_path(path) 25 | File.exist?(full_path) 26 | end 27 | 28 | def has_file_with_contents_of?(path) 29 | expected_contents = @templates[File.basename(path)] 30 | all_paths.any? do |path| 31 | File.open(path, 'rb', &:read) == expected_contents 32 | end 33 | end 34 | 35 | def files_named_like(name) 36 | all_paths.select do |path| 37 | path.include?(name) 38 | end 39 | end 40 | 41 | def has_directory?(path) 42 | full_path = temp_path(path) 43 | File.directory?(full_path) 44 | end 45 | 46 | def file_contents(path) 47 | full_path = temp_path(path) 48 | File.open(full_path, 'rb', &:read) 49 | end 50 | 51 | def temp_path(path) 52 | File.expand_path(path, temp_dir) 53 | end 54 | 55 | def all_paths 56 | Dir[temp_path('**/*')] 57 | end 58 | 59 | end 60 | -------------------------------------------------------------------------------- /ftpd.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # frozen_string_literal: true 3 | 4 | require File.join(File.dirname(__FILE__), "lib/ftpd/release") 5 | 6 | class Readme 7 | 8 | def description 9 | readme = File.open(README_PATH, "r", &:read) 10 | description = readme[/^# FTPD.*\n+((?:.*\n)+?)\n*##/i, 1] 11 | unless description 12 | raise "Unable to extract description from readme" 13 | end 14 | description = remove_badges(description) 15 | description = remove_markdown_link(description) 16 | description = join_lines(description) 17 | description 18 | end 19 | 20 | private 21 | 22 | README_PATH = File.expand_path("README.md", File.dirname(__FILE__)) 23 | private_constant :README_PATH 24 | 25 | def remove_markdown_link(description) 26 | regex = %r{ 27 | \[ 28 | ([^\]]+) 29 | \] 30 | ( 31 | \[\d+\] | 32 | \([^)]+\) 33 | ) 34 | }x 35 | description = description.gsub(regex, '\1') 36 | end 37 | 38 | def remove_badges(description) 39 | description.gsub(/^\[!.*\n/, "") 40 | end 41 | 42 | def join_lines(description) 43 | description.gsub(/\n/, " ").strip 44 | end 45 | 46 | end 47 | 48 | Gem::Specification.new do |s| 49 | s.name = "ftpd" 50 | s.version = Ftpd::Release::VERSION 51 | s.required_rubygems_version = Gem::Requirement.new(">= 0") 52 | s.require_paths = ["lib"] 53 | s.authors = ["Wayne Conrad"] 54 | s.date = Ftpd::Release::DATE 55 | s.description = Readme.new.description 56 | s.email = "kf7qga@gmail.com" 57 | s.executables = ["ftpdrb"] 58 | s.extra_rdoc_files = [ 59 | "LICENSE.md", 60 | "README.md" 61 | ] 62 | s.files = [ 63 | ".yardopts", 64 | "Changelog.md", 65 | "Gemfile", 66 | "Gemfile.lock", 67 | "LICENSE.md", 68 | "README.md", 69 | "Rakefile", 70 | "bin/ftpdrb", 71 | "ftpd.gemspec", 72 | "insecure-test-cert.pem", 73 | ] 74 | s.files += Dir["doc/**/*.md"] 75 | s.files += Dir["examples/**/*.rb"] 76 | s.files += Dir["lib/**/*.rb"] 77 | s.homepage = "http://github.com/wconrad/ftpd" 78 | s.licenses = ["MIT"] 79 | s.required_ruby_version = ">= 1.9.3" 80 | s.rubygems_version = "2.5.1" 81 | s.summary = "Pure Ruby FTP server library" 82 | s.add_runtime_dependency("memoizer", "~> 1.0") 83 | s.add_development_dependency("cucumber", "~> 2.0") 84 | s.add_development_dependency("double-bag-ftps", "~> 0.1", ">= 0.1.4") 85 | s.add_development_dependency("rake", "~> 11.1") 86 | s.add_development_dependency("redcarpet", "~> 3.1") 87 | s.add_development_dependency("rspec", "~> 3.1") 88 | s.add_development_dependency("rspec-its", "~> 1.0") 89 | s.add_development_dependency("timecop", "~> 0.7") 90 | s.add_development_dependency("yard", "~> 0.8.7") 91 | end 92 | -------------------------------------------------------------------------------- /insecure-test-cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICIzCCAYwCCQDPNA1ZOq8CbzANBgkqhkiG9w0BAQUFADBWMQswCQYDVQQGEwJV 3 | UzEPMA0GA1UECAwGRGVuaWFsMRQwEgYDVQQHDAtTcHJpbmdmaWVsZDEMMAoGA1UE 4 | CgwDRGlzMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMTIwNjIyMTMyMTI1WhcNMjIw 5 | NjIwMTMyMTI1WjBWMQswCQYDVQQGEwJVUzEPMA0GA1UECAwGRGVuaWFsMRQwEgYD 6 | VQQHDAtTcHJpbmdmaWVsZDEMMAoGA1UECgwDRGlzMRIwEAYDVQQDDAlsb2NhbGhv 7 | c3QwgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAKyacIYurNopaS1tHCE5VrUb 8 | 1tFveP5kWm6kyeE42dYFMcb0wSKofKDWPju+jEwxZ/SLBnF/IvDKqfFH8A7bzdTi 9 | mdtiWZgqMjs1QxFWF3ohoHm0bB2l0zSWufefjSjstSJanazOW4seq3Zm9ut233Mm 10 | 7h2fKgmM8mzUIKqqLCFfAgMBAAEwDQYJKoZIhvcNAQEFBQADgYEAJdwBZm4BI+7y 11 | Ul0TJtzdLuKo5lKJXX/l8v7hlvziR6Vbv0H7bdqJ+5N3DLDDHZ4DEbypP67lxf2i 12 | cyKdbash/nJsMUVUr+MsvJ9VyRRiSyhzqCN/RgaN9nx4Z0fl5I8aQp2qcZi1t8R5 13 | ZLgk9oqiPOEca6i22DDBSg0cnhBH9Lk= 14 | -----END CERTIFICATE----- 15 | -----BEGIN RSA PRIVATE KEY----- 16 | MIICXAIBAAKBgQCsmnCGLqzaKWktbRwhOVa1G9bRb3j+ZFpupMnhONnWBTHG9MEi 17 | qHyg1j47voxMMWf0iwZxfyLwyqnxR/AO283U4pnbYlmYKjI7NUMRVhd6IaB5tGwd 18 | pdM0lrn3n40o7LUiWp2szluLHqt2Zvbrdt9zJu4dnyoJjPJs1CCqqiwhXwIDAQAB 19 | AoGAcyj/1qchsNVcVXCtCgXFskSGyWnEooa2R4gvIdPak48XrRT0H3mm3XDUSOxT 20 | kyqLn396pxMabunpBRDoPCGvbDdphhcSKIJPRga0LJBnMVp87xeaw0JvNB1EsdzP 21 | xbsXwSt39zjJeAE1IAOgMCHC/GRisvRnkZuKOM7XYe7UAGECQQDeawf/5Zgfcvgc 22 | Bqxv6ZPCJxAh7FKyWDUqa8RTtD0qWuXOYnlVzaEkN8FfimrPdmQBx+eMacsqCrLG 23 | v8hvubt5AkEAxqnzo+IU4bsUdezndEjYKOVH3qs9qO+8bneKCqD4h2k2Db2va8OG 24 | sP44hRMvgkkRiZjHWAUeG+ytgWGU7CK1lwJAe1fvv8GbcxVW8nPg/M8T2f+/upBL 25 | 7AtusG/DGIhDw1FVT/bcQvEeA+/HlSw1v4dwPmyVxBCHUnFMY1vH0+20QQJBAI6s 26 | 4eCx7qNLM1+Z24RFCJEeUWZWfzsDqcWALnCBuNuvMPXfY8u2KdaVTUwtQjKEfYbf 27 | ZVMOodgWO2mvBkAskVMCQE98evHiZkDEpVU89TbbClYpmGOSRjQTrXEaVePLb0Hr 28 | GwylNdJEAClM4gK+GnXa4m57xs13eBwuXsM+77fdU2I= 29 | -----END RSA PRIVATE KEY----- 30 | -------------------------------------------------------------------------------- /lib/ftpd.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Standard libraries 4 | require 'fileutils' 5 | require 'forwardable' 6 | require 'logger' 7 | require 'openssl' 8 | require 'pathname' 9 | require 'shellwords' 10 | require 'socket' 11 | require 'strscan' 12 | require 'thread' 13 | require 'tmpdir' 14 | 15 | # Gems 16 | require 'memoizer' 17 | 18 | # Ftpd 19 | require_relative 'ftpd/auth_levels' 20 | require_relative 'ftpd/cmd_abor' 21 | require_relative 'ftpd/cmd_allo' 22 | require_relative 'ftpd/cmd_appe' 23 | require_relative 'ftpd/cmd_auth' 24 | require_relative 'ftpd/cmd_cdup' 25 | require_relative 'ftpd/cmd_cwd' 26 | require_relative 'ftpd/cmd_dele' 27 | require_relative 'ftpd/cmd_eprt' 28 | require_relative 'ftpd/cmd_epsv' 29 | require_relative 'ftpd/cmd_feat' 30 | require_relative 'ftpd/cmd_help' 31 | require_relative 'ftpd/cmd_list' 32 | require_relative 'ftpd/cmd_login' 33 | require_relative 'ftpd/cmd_mdtm' 34 | require_relative 'ftpd/cmd_mkd' 35 | require_relative 'ftpd/cmd_mode' 36 | require_relative 'ftpd/cmd_nlst' 37 | require_relative 'ftpd/cmd_noop' 38 | require_relative 'ftpd/cmd_opts' 39 | require_relative 'ftpd/cmd_pasv' 40 | require_relative 'ftpd/cmd_pbsz' 41 | require_relative 'ftpd/cmd_port' 42 | require_relative 'ftpd/cmd_prot' 43 | require_relative 'ftpd/cmd_pwd' 44 | require_relative 'ftpd/cmd_quit' 45 | require_relative 'ftpd/cmd_rein' 46 | require_relative 'ftpd/cmd_rename' 47 | require_relative 'ftpd/cmd_rest' 48 | require_relative 'ftpd/cmd_retr' 49 | require_relative 'ftpd/cmd_rmd' 50 | require_relative 'ftpd/cmd_site' 51 | require_relative 'ftpd/cmd_size' 52 | require_relative 'ftpd/cmd_smnt' 53 | require_relative 'ftpd/cmd_stat' 54 | require_relative 'ftpd/cmd_stor' 55 | require_relative 'ftpd/cmd_stou' 56 | require_relative 'ftpd/cmd_stru' 57 | require_relative 'ftpd/cmd_syst' 58 | require_relative 'ftpd/cmd_type' 59 | require_relative 'ftpd/command_handler' 60 | require_relative 'ftpd/command_handler_factory' 61 | require_relative 'ftpd/command_handlers' 62 | require_relative 'ftpd/command_loop' 63 | require_relative 'ftpd/command_sequence_checker' 64 | require_relative 'ftpd/connection_throttle' 65 | require_relative 'ftpd/connection_tracker' 66 | require_relative 'ftpd/data_connection_helper' 67 | require_relative 'ftpd/disk_file_system' 68 | require_relative 'ftpd/error' 69 | require_relative 'ftpd/exception_translator' 70 | require_relative 'ftpd/exceptions' 71 | require_relative 'ftpd/file_info' 72 | require_relative 'ftpd/file_system_helper' 73 | require_relative 'ftpd/ftp_server' 74 | require_relative 'ftpd/insecure_certificate' 75 | require_relative 'ftpd/list_format/eplf' 76 | require_relative 'ftpd/list_format/ls' 77 | require_relative 'ftpd/list_path' 78 | require_relative 'ftpd/null_logger' 79 | require_relative 'ftpd/protocols' 80 | require_relative 'ftpd/release' 81 | require_relative 'ftpd/server' 82 | require_relative 'ftpd/session' 83 | require_relative 'ftpd/session_config' 84 | require_relative 'ftpd/stream' 85 | require_relative 'ftpd/telnet' 86 | require_relative 'ftpd/temp_dir' 87 | require_relative 'ftpd/tls_server' 88 | require_relative 'ftpd/translate_exceptions' 89 | -------------------------------------------------------------------------------- /lib/ftpd/auth_levels.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | # Authorization levels for FtpServer#auth_level 6 | 7 | AUTH_USER = 0 8 | AUTH_PASSWORD = 1 9 | AUTH_ACCOUNT = 2 10 | 11 | end 12 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_abor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdAbor < CommandHandler 8 | 9 | def cmd_abor(argument) 10 | unimplemented_error 11 | end 12 | 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_allo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | # The Allocate (ALLO) command. 8 | # 9 | # This server does not need the ALLO command, so treats it as a 10 | # NOOP. 11 | 12 | class CmdAllo < CommandHandler 13 | 14 | def cmd_allo(argument) 15 | ensure_logged_in 16 | syntax_error unless argument =~ /^\d+( R \d+)?$/ 17 | command_not_needed 18 | end 19 | 20 | end 21 | 22 | end 23 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_appe.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdAppe < CommandHandler 8 | 9 | def cmd_appe(argument) 10 | close_data_server_socket_when_done do 11 | ensure_logged_in 12 | ensure_file_system_supports :append 13 | path = argument 14 | syntax_error unless path 15 | path = File.expand_path(path, name_prefix) 16 | ensure_accessible path 17 | receive_file do |data_socket| 18 | file_system.append path, data_socket 19 | end 20 | reply "226 Transfer complete" 21 | end 22 | end 23 | 24 | end 25 | 26 | end 27 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_auth.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdAuth < CommandHandler 8 | 9 | def cmd_auth(security_scheme) 10 | ensure_tls_supported 11 | if socket.encrypted? 12 | error "AUTH already done", 503 13 | end 14 | unless security_scheme =~ /^TLS(-C)?$/i 15 | error "Security scheme not implemented: #{security_scheme}", 504 16 | end 17 | reply "234 AUTH #{security_scheme} OK." 18 | socket.encrypt 19 | end 20 | 21 | end 22 | 23 | end 24 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_cdup.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdCdup < CommandHandler 8 | 9 | def cmd_cdup(argument) 10 | syntax_error if argument 11 | ensure_logged_in 12 | execute_command 'cwd', '..' 13 | end 14 | alias cmd_xcup :cmd_cdup 15 | 16 | end 17 | 18 | end 19 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_cwd.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdCwd < CommandHandler 8 | 9 | def cmd_cwd(argument) 10 | ensure_logged_in 11 | path = File.expand_path(argument, name_prefix) 12 | ensure_accessible path 13 | ensure_exists path 14 | ensure_directory path 15 | self.name_prefix = path 16 | pwd 250 17 | end 18 | alias cmd_xcwd :cmd_cwd 19 | 20 | end 21 | 22 | end 23 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_dele.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdDele < CommandHandler 8 | 9 | def cmd_dele(argument) 10 | ensure_logged_in 11 | ensure_file_system_supports :delete 12 | path = argument 13 | error "Path required", 501 unless path 14 | path = File.expand_path(path, name_prefix) 15 | ensure_accessible path 16 | ensure_exists path 17 | file_system.delete path 18 | reply "250 DELE command successful" 19 | end 20 | 21 | end 22 | 23 | end 24 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_eprt.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdEprt < CommandHandler 8 | 9 | def cmd_eprt(argument) 10 | ensure_logged_in 11 | ensure_not_epsv_all 12 | delim = argument[0..0] 13 | parts = argument.split(delim)[1..-1] 14 | syntax_error unless parts.size == 3 15 | protocol_code, address, port = *parts 16 | protocol_code = protocol_code.to_i 17 | ensure_protocol_supported protocol_code 18 | port = port.to_i 19 | set_active_mode_address address, port 20 | reply "200 EPRT command successful" 21 | end 22 | 23 | end 24 | 25 | end 26 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_epsv.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | require_relative 'data_server_factory' 5 | 6 | module Ftpd 7 | 8 | class CmdEpsv < CommandHandler 9 | 10 | def cmd_epsv(argument) 11 | ensure_logged_in 12 | if data_server 13 | reply "200 Already in passive mode" 14 | else 15 | if argument == 'ALL' 16 | self.epsv_all = true 17 | reply "220 EPSV now required for port setup" 18 | else 19 | protocol_code = argument && argument.to_i 20 | if protocol_code 21 | ensure_protocol_supported protocol_code 22 | end 23 | self.data_server = data_server_factory.make_tcp_server 24 | port = data_server.addr[1] 25 | reply "229 Entering extended passive mode (|||#{port}|)" 26 | end 27 | end 28 | end 29 | 30 | end 31 | 32 | end 33 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_feat.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdFeat < CommandHandler 8 | 9 | def cmd_feat(argument) 10 | syntax_error if argument 11 | reply '211-Extensions supported:' 12 | extensions.each do |extension| 13 | reply " #{extension}" 14 | end 15 | reply '211 END' 16 | end 17 | 18 | private 19 | 20 | def extensions 21 | [ 22 | (TLS_EXTENSIONS if tls_enabled?), 23 | IPV6_EXTENSIONS, 24 | RFC_3659_EXTENSIONS, 25 | ].flatten.compact 26 | end 27 | 28 | TLS_EXTENSIONS = [ 29 | 'AUTH TLS', 30 | 'PBSZ', 31 | 'PROT' 32 | ] 33 | 34 | IPV6_EXTENSIONS = [ 35 | 'EPRT', 36 | 'EPSV', 37 | ] 38 | 39 | RFC_3659_EXTENSIONS = [ 40 | 'MDTM', 41 | 'SIZE', 42 | ] 43 | 44 | end 45 | 46 | end 47 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_help.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdHelp < CommandHandler 8 | 9 | def cmd_help(argument) 10 | if argument 11 | target_command = argument.upcase 12 | if supported_commands.include?(target_command) 13 | reply "214 Command #{target_command} is recognized" 14 | else 15 | reply "214 Command #{target_command} is not recognized" 16 | end 17 | else 18 | reply '214-The following commands are recognized:' 19 | supported_commands.sort.each_slice(8) do |commands| 20 | line = commands.map do |command| 21 | ' %-4s' % command 22 | end.join 23 | reply line 24 | end 25 | reply '214 Have a nice day.' 26 | end 27 | end 28 | 29 | end 30 | 31 | end 32 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdList < CommandHandler 8 | 9 | def cmd_list(argument) 10 | close_data_server_socket_when_done do 11 | ensure_logged_in 12 | ensure_file_system_supports :dir 13 | ensure_file_system_supports :file_info 14 | path = list_path(argument) 15 | path = File.expand_path(path, name_prefix) 16 | transmit_file(StringIO.new(list(path)), 'A') 17 | end 18 | end 19 | 20 | private 21 | 22 | def list(path) 23 | format_list(path_list(path)) 24 | end 25 | 26 | def format_list(paths) 27 | paths.map do |path| 28 | file_info = file_system.file_info(path) 29 | config.list_formatter.new(file_info).to_s + "\n" 30 | end.join 31 | end 32 | 33 | end 34 | 35 | end 36 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_login.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | # The login commands 8 | 9 | class CmdLogin < CommandHandler 10 | 11 | # @return [String] The user for the current login sequence 12 | 13 | attr_accessor :user 14 | 15 | # @return [String] The password for the current login sequence 16 | 17 | attr_accessor :password 18 | 19 | def initialize(*) 20 | super 21 | @user = nil 22 | @password = nil 23 | end 24 | 25 | # * User Name (USER) command. 26 | 27 | def cmd_user(argument) 28 | syntax_error unless argument 29 | sequence_error if logged_in 30 | @user = argument 31 | if config.auth_level > AUTH_USER 32 | reply "331 Password required" 33 | expect 'pass' 34 | else 35 | login @user 36 | end 37 | end 38 | 39 | # The Password (PASS) command 40 | 41 | def cmd_pass(argument) 42 | syntax_error unless argument 43 | @password = argument 44 | if config.auth_level > AUTH_PASSWORD 45 | reply "332 Account required" 46 | expect 'acct' 47 | else 48 | login @user, @password 49 | end 50 | end 51 | 52 | # The Account (ACCT) command 53 | 54 | def cmd_acct(argument) 55 | syntax_error unless argument 56 | account = argument 57 | login @user, @password, account 58 | end 59 | 60 | end 61 | 62 | end 63 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_mdtm.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdMdtm < CommandHandler 8 | 9 | def cmd_mdtm(path) 10 | ensure_logged_in 11 | ensure_file_system_supports :dir 12 | ensure_file_system_supports :file_info 13 | syntax_error unless path 14 | path = File.expand_path(path, name_prefix) 15 | ensure_accessible(path) 16 | ensure_exists(path) 17 | info = file_system.file_info(path) 18 | mtime = info.mtime.utc 19 | # We would like to report fractional seconds, too. Sadly, the 20 | # spec declares that we may not report more precision than is 21 | # actually there, and there is no spec or API to tell us how 22 | # many fractional digits are significant. 23 | mtime = mtime.strftime("%Y%m%d%H%M%S") 24 | reply "213 #{mtime}" 25 | end 26 | 27 | end 28 | 29 | end 30 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_mkd.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdMkd < CommandHandler 8 | 9 | def cmd_mkd(argument) 10 | syntax_error unless argument 11 | ensure_logged_in 12 | ensure_file_system_supports :mkdir 13 | path = File.expand_path(argument, name_prefix) 14 | ensure_accessible path 15 | ensure_exists File.dirname(path) 16 | ensure_directory File.dirname(path) 17 | ensure_does_not_exist path 18 | file_system.mkdir path 19 | reply %Q'257 "#{path}" created' 20 | end 21 | alias cmd_xmkd :cmd_mkd 22 | 23 | end 24 | 25 | end 26 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_mode.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdMode < CommandHandler 8 | 9 | def cmd_mode(argument) 10 | syntax_error unless argument 11 | ensure_logged_in 12 | name, implemented = TRANSMISSION_MODES[argument] 13 | error "Invalid mode code", 504 unless name 14 | error "Mode not implemented", 504 unless implemented 15 | self.mode = argument 16 | reply "200 Mode set to #{name}" 17 | end 18 | 19 | private 20 | 21 | TRANSMISSION_MODES = { 22 | 'B'=>['Block', false], 23 | 'C'=>['Compressed', false], 24 | 'S'=>['Stream', true], 25 | } 26 | 27 | end 28 | 29 | end 30 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_nlst.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdNlst < CommandHandler 8 | 9 | def cmd_nlst(argument) 10 | close_data_server_socket_when_done do 11 | ensure_logged_in 12 | ensure_file_system_supports :dir 13 | path = list_path(argument) 14 | path = File.expand_path(path, name_prefix) 15 | transmit_file(StringIO.new(name_list(path)), 'A') 16 | end 17 | end 18 | 19 | private 20 | 21 | def name_list(target_path) 22 | path_list(target_path).map do |path| 23 | File.basename(path) + "\n" 24 | end.join 25 | end 26 | 27 | end 28 | 29 | end 30 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_noop.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdNoop < CommandHandler 8 | 9 | def cmd_noop(argument) 10 | syntax_error if argument 11 | reply "200 Nothing done" 12 | end 13 | 14 | end 15 | 16 | end 17 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_opts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdOpts < CommandHandler 8 | 9 | def cmd_opts(argument) 10 | syntax_error unless argument 11 | error 'Unsupported option', 501 12 | end 13 | 14 | end 15 | 16 | end 17 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_pasv.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | require_relative 'data_server_factory' 5 | 6 | module Ftpd 7 | 8 | class CmdPasv < CommandHandler 9 | 10 | def cmd_pasv(argument) 11 | ensure_logged_in 12 | ensure_not_epsv_all 13 | if data_server 14 | reply "200 Already in passive mode" 15 | else 16 | self.data_server = data_server_factory.make_tcp_server 17 | ip = config.nat_ip || data_server.addr[3] 18 | port = data_server.addr[1] 19 | quads = [ 20 | ip.scan(/\d+/), 21 | port >> 8, 22 | port & 0xff, 23 | ].flatten.join(',') 24 | reply "227 Entering passive mode (#{quads})" 25 | end 26 | end 27 | 28 | end 29 | 30 | end 31 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_pbsz.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdPbsz < CommandHandler 8 | 9 | def cmd_pbsz(buffer_size) 10 | ensure_tls_supported 11 | syntax_error unless buffer_size =~ /^\d+$/ 12 | buffer_size = buffer_size.to_i 13 | unless socket.encrypted? 14 | error "PBSZ must be preceded by AUTH", 503 15 | end 16 | unless buffer_size == 0 17 | error "PBSZ=0", 501 18 | end 19 | reply "200 PBSZ=0" 20 | self.protection_buffer_size_set = true 21 | end 22 | 23 | end 24 | 25 | end 26 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_port.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | # The Data Port (PORT) command. 8 | 9 | class CmdPort < CommandHandler 10 | 11 | def cmd_port(argument) 12 | ensure_logged_in 13 | ensure_not_epsv_all 14 | pieces = argument.split(/,/) 15 | syntax_error unless pieces.size == 6 16 | pieces.collect! do |s| 17 | syntax_error unless s =~ /^\d{1,3}$/ 18 | i = s.to_i 19 | syntax_error unless (0..255) === i 20 | i 21 | end 22 | hostname = pieces[0..3].join('.') 23 | port = pieces[4] << 8 | pieces[5] 24 | set_active_mode_address hostname, port 25 | reply "200 PORT command successful" 26 | end 27 | 28 | end 29 | 30 | end 31 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_prot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdProt < CommandHandler 8 | 9 | def cmd_prot(level_arg) 10 | level_code = level_arg.upcase 11 | unless protection_buffer_size_set 12 | error "PROT must be preceded by PBSZ", 503 13 | end 14 | level = DATA_CHANNEL_PROTECTION_LEVELS[level_code] 15 | unless level 16 | error "Unknown protection level", 504 17 | end 18 | unless level == :private 19 | error "Unsupported protection level #{level}", 536 20 | end 21 | self.data_channel_protection_level = level 22 | reply "200 Data protection level #{level_code}" 23 | end 24 | 25 | private 26 | 27 | DATA_CHANNEL_PROTECTION_LEVELS = { 28 | 'C'=>:clear, 29 | 'S'=>:safe, 30 | 'E'=>:confidential, 31 | 'P'=>:private 32 | } 33 | 34 | end 35 | 36 | end 37 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_pwd.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdPwd < CommandHandler 8 | 9 | def cmd_pwd(argument) 10 | ensure_logged_in 11 | pwd 257 12 | end 13 | alias cmd_xpwd :cmd_pwd 14 | 15 | end 16 | 17 | end 18 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_quit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | # The Quit (QUIT) command 8 | 9 | class CmdQuit < CommandHandler 10 | 11 | def cmd_quit(argument) 12 | syntax_error if argument 13 | ensure_logged_in 14 | reply "221 Byebye" 15 | self.logged_in = false 16 | end 17 | 18 | end 19 | 20 | end 21 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_rein.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdRein < CommandHandler 8 | 9 | def cmd_rein(argument) 10 | unimplemented_error 11 | end 12 | 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_rename.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdRename < CommandHandler 8 | 9 | def cmd_rnfr(argument) 10 | ensure_logged_in 11 | ensure_file_system_supports :rename 12 | syntax_error unless argument 13 | from_path = File.expand_path(argument, name_prefix) 14 | ensure_accessible from_path 15 | ensure_exists from_path 16 | @rename_from_path = from_path 17 | reply '350 RNFR accepted; ready for destination' 18 | expect 'rnto' 19 | end 20 | 21 | def cmd_rnto(argument) 22 | ensure_logged_in 23 | ensure_file_system_supports :rename 24 | syntax_error unless argument 25 | to_path = File.expand_path(argument, name_prefix) 26 | ensure_accessible to_path 27 | ensure_does_not_exist to_path 28 | file_system.rename(@rename_from_path, to_path) 29 | reply '250 Rename successful' 30 | end 31 | 32 | end 33 | 34 | end 35 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_rest.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdRest < CommandHandler 8 | 9 | def cmd_rest(argument) 10 | unimplemented_error 11 | end 12 | 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_retr.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdRetr < CommandHandler 8 | 9 | def cmd_retr(argument) 10 | close_data_server_socket_when_done do 11 | ensure_logged_in 12 | ensure_file_system_supports :read 13 | path = argument 14 | syntax_error unless path 15 | path = File.expand_path(path, name_prefix) 16 | ensure_accessible path 17 | ensure_exists path 18 | file_system.read(path) do |file| 19 | transmit_file file 20 | end 21 | end 22 | end 23 | 24 | end 25 | 26 | end 27 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_rmd.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdRmd < CommandHandler 8 | 9 | def cmd_rmd(argument) 10 | syntax_error unless argument 11 | ensure_logged_in 12 | ensure_file_system_supports :rmdir 13 | path = File.expand_path(argument, name_prefix) 14 | ensure_accessible path 15 | ensure_exists path 16 | ensure_directory path 17 | file_system.rmdir path 18 | reply '250 RMD command successful' 19 | end 20 | alias cmd_xrmd :cmd_rmd 21 | 22 | end 23 | 24 | end 25 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_site.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdSite < CommandHandler 8 | 9 | def cmd_site(argument) 10 | unimplemented_error 11 | end 12 | 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_size.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdSize < CommandHandler 8 | 9 | def cmd_size(path) 10 | ensure_logged_in 11 | ensure_file_system_supports :read 12 | syntax_error unless path 13 | path = File.expand_path(path, name_prefix) 14 | ensure_accessible(path) 15 | ensure_exists(path) 16 | file_system.read(path) do |file| 17 | if data_type == 'A' 18 | output = StringIO.new 19 | io = Ftpd::Stream.new(output, data_type) 20 | io.write(file) 21 | size = output.size 22 | else 23 | size = file.size 24 | end 25 | reply "213 #{size}" 26 | end 27 | end 28 | 29 | end 30 | 31 | end 32 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_smnt.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdSmnt < CommandHandler 8 | 9 | def cmd_smnt(argument) 10 | unimplemented_error 11 | end 12 | 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_stat.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdStat < CommandHandler 8 | 9 | def cmd_stat(argument) 10 | ensure_logged_in 11 | syntax_error if argument 12 | reply "211 #{server_name_and_version}" 13 | end 14 | 15 | end 16 | 17 | end 18 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_stor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdStor < CommandHandler 8 | 9 | def cmd_stor(argument) 10 | close_data_server_socket_when_done do 11 | ensure_logged_in 12 | ensure_file_system_supports :write 13 | path = argument 14 | syntax_error unless path 15 | path = File.expand_path(path, name_prefix) 16 | ensure_accessible path 17 | ensure_exists File.dirname(path) 18 | receive_file do |data_socket| 19 | file_system.write path, data_socket 20 | end 21 | reply "226 Transfer complete" 22 | end 23 | end 24 | 25 | end 26 | 27 | end 28 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_stou.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdStou < CommandHandler 8 | 9 | def cmd_stou(argument) 10 | close_data_server_socket_when_done do 11 | ensure_logged_in 12 | ensure_file_system_supports :write 13 | path = argument || 'ftpd' 14 | path = File.expand_path(path, name_prefix) 15 | path = unique_path(path) 16 | ensure_accessible path 17 | ensure_exists File.dirname(path) 18 | receive_file(File.basename(path)) do |data_socket| 19 | file_system.write path, data_socket 20 | end 21 | reply "226 Transfer complete" 22 | end 23 | end 24 | 25 | end 26 | 27 | end 28 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_stru.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdStru < CommandHandler 8 | 9 | def cmd_stru(argument) 10 | syntax_error unless argument 11 | ensure_logged_in 12 | name, implemented = FILE_STRUCTURES[argument] 13 | error "Invalid structure code", 504 unless name 14 | error "Structure not implemented", 504 unless implemented 15 | self.structure = argument 16 | reply "200 File structure set to #{name}" 17 | end 18 | 19 | private 20 | 21 | FILE_STRUCTURES = { 22 | 'R'=>['Record', false], 23 | 'F'=>['File', true], 24 | 'P'=>['Page', false], 25 | } 26 | 27 | end 28 | 29 | end 30 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_syst.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | # The System (SYST) command. 8 | 9 | class CmdSyst < CommandHandler 10 | 11 | def cmd_syst(argument) 12 | syntax_error if argument 13 | reply "215 UNIX Type: L8" 14 | end 15 | 16 | end 17 | 18 | end 19 | -------------------------------------------------------------------------------- /lib/ftpd/cmd_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'command_handler' 4 | 5 | module Ftpd 6 | 7 | class CmdType < CommandHandler 8 | 9 | def cmd_type(argument) 10 | ensure_logged_in 11 | syntax_error unless argument =~ /^\S(?: \S+)?$/ 12 | unless argument =~ /^([AEI]( [NTC])?|L .*)$/ 13 | error 'Invalid type code', 504 14 | end 15 | case argument 16 | when /^A( [NT])?$/ 17 | self.data_type = 'A' 18 | when /^(I|L 8)$/ 19 | self.data_type = 'I' 20 | else 21 | error 'Type not implemented', 504 22 | end 23 | reply "200 Type set to #{data_type}" 24 | end 25 | 26 | end 27 | 28 | end 29 | -------------------------------------------------------------------------------- /lib/ftpd/command_handler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'data_connection_helper' 4 | require_relative 'error' 5 | require_relative 'file_system_helper' 6 | 7 | module Ftpd 8 | 9 | # Command handler base class 10 | 11 | class CommandHandler 12 | 13 | extend Forwardable 14 | 15 | include DataConnectionHelper 16 | include Error 17 | include FileSystemHelper 18 | 19 | COMMAND_FILENAME_PREFIX = 'cmd_' 20 | COMMAND_KLASS_PREFIX = 'Cmd' 21 | COMMAND_METHOD_PREFIX = 'cmd_' 22 | 23 | # param session [Session] The session 24 | 25 | def initialize(session) 26 | @session = session 27 | end 28 | 29 | # Return the commands implemented by this handler. For example, 30 | # if the handler has the method "cmd_allo", this returns ['allo']. 31 | 32 | class << self 33 | include Memoizer 34 | def commands 35 | public_instance_methods.map(&:to_s).grep(/#{COMMAND_METHOD_PREFIX}/).map do |method| 36 | method.gsub(/^#{COMMAND_METHOD_PREFIX}/, '') 37 | end 38 | end 39 | memoize :commands 40 | end 41 | 42 | def_delegator 'self.class', :commands 43 | 44 | private 45 | 46 | attr_reader :session 47 | 48 | # Forward methods to the session 49 | 50 | def_delegators :@session, 51 | :close_data_server_socket, 52 | :command_not_needed, 53 | :config, 54 | :data_channel_protection_level, 55 | :data_channel_protection_level=, 56 | :data_hostname, 57 | :data_port, 58 | :data_server, 59 | :data_server=, 60 | :data_server_factory, 61 | :data_type, 62 | :data_type=, 63 | :ensure_logged_in, 64 | :ensure_not_epsv_all, 65 | :ensure_protocol_supported, 66 | :ensure_tls_supported, 67 | :epsv_all=, 68 | :execute_command, 69 | :expect, 70 | :file_system, 71 | :list, 72 | :list_path, 73 | :logged_in, 74 | :logged_in=, 75 | :login, 76 | :mode=, 77 | :name_list, 78 | :name_prefix, 79 | :name_prefix=, 80 | :protection_buffer_size_set, 81 | :protection_buffer_size_set=, 82 | :pwd, 83 | :reply, 84 | :server_name_and_version, 85 | :set_active_mode_address, 86 | :socket, 87 | :structure=, 88 | :supported_commands, 89 | :tls_enabled? 90 | 91 | end 92 | 93 | end 94 | -------------------------------------------------------------------------------- /lib/ftpd/command_handler_factory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | class CommandHandlerFactory 6 | 7 | def self.standard_command_handlers 8 | [ 9 | CmdAbor, 10 | CmdAllo, 11 | CmdAppe, 12 | CmdAuth, 13 | CmdCdup, 14 | CmdCwd, 15 | CmdDele, 16 | CmdEprt, 17 | CmdEpsv, 18 | CmdFeat, 19 | CmdHelp, 20 | CmdList, 21 | CmdLogin, 22 | CmdMdtm, 23 | CmdMkd, 24 | CmdMode, 25 | CmdNlst, 26 | CmdNoop, 27 | CmdOpts, 28 | CmdPasv, 29 | CmdPbsz, 30 | CmdPort, 31 | CmdProt, 32 | CmdPwd, 33 | CmdQuit, 34 | CmdRein, 35 | CmdRename, 36 | CmdRest, 37 | CmdRetr, 38 | CmdRmd, 39 | CmdSite, 40 | CmdSize, 41 | CmdSmnt, 42 | CmdStat, 43 | CmdStor, 44 | CmdStou, 45 | CmdStru, 46 | CmdSyst, 47 | CmdType, 48 | ] 49 | end 50 | 51 | end 52 | 53 | end 54 | -------------------------------------------------------------------------------- /lib/ftpd/command_handlers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | # All FTP commands which the server supports are dispatched by this 6 | # class. 7 | 8 | class CommandHandlers 9 | 10 | def initialize 11 | @commands = {} 12 | end 13 | 14 | # Add a command handler 15 | # 16 | # @param command_handler [Command] 17 | 18 | def <<(command_handler) 19 | command_handler.commands.each do |command| 20 | @commands[command] = command_handler 21 | end 22 | end 23 | 24 | # @param command [String] the command (e.g. "STOR"). Case 25 | # insensitive. 26 | # @return truthy if the server supports the command. 27 | 28 | def has?(command) 29 | command = canonical_command(command) 30 | @commands.has_key?(command) 31 | end 32 | 33 | # Dispatch a command to the appropriate command handler. 34 | # 35 | # @param command [String] the command (e.g. "STOR"). Case 36 | # insensitive. 37 | # @param argument [String] The argument, or nil if there isn't 38 | # one. 39 | 40 | def execute(command, argument) 41 | command = canonical_command(command) 42 | method = "cmd_#{command}" 43 | @commands[command.downcase].send(method, argument) 44 | end 45 | 46 | # Return the sorted list of commands supported by this handler 47 | # 48 | # @return [Array] Lowercase command 49 | 50 | def commands 51 | @commands.keys.sort 52 | end 53 | 54 | private 55 | 56 | def canonical_command(command) 57 | command.downcase 58 | end 59 | 60 | end 61 | 62 | end 63 | -------------------------------------------------------------------------------- /lib/ftpd/command_loop.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | class CommandLoop 6 | 7 | extend Forwardable 8 | 9 | include Error 10 | 11 | def initialize(session) 12 | @session = session 13 | end 14 | 15 | def read_and_execute_commands 16 | catch :done do 17 | begin 18 | reply "220 #{server_name_and_version}" 19 | loop do 20 | begin 21 | s = get_command 22 | s = process_telnet_sequences(s) 23 | syntax_error unless s =~ /^(\w+)(?: (.*))?$/ 24 | command, argument = $1.downcase, $2 25 | unless valid_command?(command) 26 | error "Syntax error, command unrecognized: #{s.chomp}", 500 27 | end 28 | command_sequence_checker.check command 29 | execute_command command, argument 30 | rescue FtpServerError => e 31 | reply e.message_with_code 32 | rescue => e 33 | reply "451 Requested action aborted. Local error in processing." 34 | config.exception_handler.call(e) unless config.exception_handler.nil? 35 | end 36 | end 37 | rescue Errno::ECONNRESET, Errno::EPIPE 38 | end 39 | end 40 | end 41 | 42 | private 43 | 44 | def_delegators :@session, 45 | :command_sequence_checker, 46 | :config, 47 | :execute_command, 48 | :reply, 49 | :server_name_and_version, 50 | :socket, 51 | :valid_command? 52 | 53 | def get_command 54 | s = gets_with_timeout(socket) 55 | throw :done if s.nil? 56 | s = s.chomp 57 | config.log.debug s.sub(/^PASS .*/, 'PASS **FILTERED**') # Filter real password 58 | s 59 | end 60 | 61 | def gets_with_timeout(socket) 62 | ready = IO.select([socket], nil, nil, config.session_timeout) 63 | timeout if ready.nil? 64 | ready[0].first.gets 65 | end 66 | 67 | def timeout 68 | reply '421 Control connection timed out.' 69 | throw :done 70 | end 71 | 72 | def process_telnet_sequences(s) 73 | telnet = Telnet.new(s) 74 | unless telnet.reply.empty? 75 | socket.write telnet.reply 76 | end 77 | telnet.plain 78 | end 79 | 80 | end 81 | 82 | end 83 | -------------------------------------------------------------------------------- /lib/ftpd/command_sequence_checker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Some commands are supposed to occur in sequence. For example, USER 4 | # must be immediately followed by PASS. This class keeps track of 5 | # when a specific command either must arrive or must not arrive, and 6 | # raises a "bad sequence" error when commands arrive in the wrong 7 | # sequence. 8 | 9 | module Ftpd 10 | class CommandSequenceChecker 11 | 12 | include Error 13 | 14 | def initialize 15 | @must_expect = [] 16 | @expected_command = nil 17 | end 18 | 19 | # Set the command to expect next. If not set, then any command 20 | # will be accepted, so long as it hasn't been registered using 21 | # {#must_expect}. Otherwise, the set command must be next or a 22 | # sequence error will result. 23 | # 24 | # @param command [String] The command. Must be lowercase. 25 | 26 | def expect(command) 27 | @expected_command = command 28 | end 29 | 30 | # Register a command that must be expected. When that command is 31 | # received without {#expect} having been called for it, a sequence 32 | # error will result. 33 | 34 | def must_expect(command) 35 | @must_expect << command 36 | end 37 | 38 | # Check a command. If expecting a specific command and this 39 | # command isn't it, then raise an error that will cause a "503 Bad 40 | # sequence" error to be sent. After checking, the expected 41 | # command is cleared and any command will be accepted until 42 | # {#expect} is called again. 43 | # 44 | # @param command [String] The command. Must be lowercase. 45 | # @raise [FtpServerError] A "503 Bad sequence" error 46 | 47 | def check(command) 48 | if @expected_command 49 | begin 50 | sequence_error unless command == @expected_command 51 | ensure 52 | @expected_command = nil 53 | end 54 | else 55 | sequence_error if @must_expect.include?(command) 56 | end 57 | end 58 | 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/ftpd/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | class Config 5 | 6 | # The number of seconds to delay before replying. This is for 7 | # testing client timeouts. 8 | # Defaults to 0 (no delay). 9 | # 10 | # Change to this attribute only take effect for new sessions. 11 | 12 | attr_accessor :response_delay 13 | 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/ftpd/connection_throttle.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | # This class limits the number of connections 6 | 7 | class ConnectionThrottle 8 | 9 | DEFAULT_MAX_CONNECTIONS = nil 10 | DEFAULT_MAX_CONNECTIONS_PER_IP = nil 11 | 12 | # The maximum number of connections, or nil if there is no limit. 13 | # @return [Integer] 14 | 15 | attr_accessor :max_connections 16 | 17 | # The maximum number of connections for an IP, or nil if there is 18 | # no limit. 19 | # @return [Integer] 20 | 21 | attr_accessor :max_connections_per_ip 22 | 23 | # @param connection_tracker [ConnectionTracker] 24 | 25 | def initialize(connection_tracker) 26 | @max_connections = DEFAULT_MAX_CONNECTIONS 27 | @max_connections_per_ip = DEFAULT_MAX_CONNECTIONS_PER_IP 28 | @connection_tracker = connection_tracker 29 | end 30 | 31 | # @return [Boolean] true if the connection should be allowed 32 | 33 | def allow?(socket) 34 | allow_by_total_count && 35 | allow_by_ip_count(socket) 36 | end 37 | 38 | # Reject a connection 39 | 40 | def deny(socket) 41 | socket.write "421 Too many connections\r\n" 42 | end 43 | 44 | private 45 | 46 | def allow_by_total_count 47 | return true unless @max_connections 48 | @connection_tracker.connections < @max_connections 49 | end 50 | 51 | def allow_by_ip_count(socket) 52 | return true unless @max_connections_per_ip 53 | @connection_tracker.connections_for(socket) < @max_connections_per_ip 54 | end 55 | 56 | end 57 | 58 | end 59 | -------------------------------------------------------------------------------- /lib/ftpd/connection_tracker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "gets_peer_address" 4 | 5 | module Ftpd 6 | 7 | # This class keeps track of connections 8 | 9 | class ConnectionTracker 10 | 11 | include GetsPeerAddress 12 | 13 | def initialize 14 | @mutex = Mutex.new 15 | @connections = {} 16 | @socket_ips ={} 17 | end 18 | 19 | # Return the total number of connections 20 | 21 | def connections 22 | @mutex.synchronize do 23 | @connections.values.inject(0, &:+) 24 | end 25 | end 26 | 27 | # Return the number of connections for a socket's peer IP 28 | 29 | def connections_for(socket) 30 | @mutex.synchronize do 31 | ip = peer_ip(socket) 32 | @connections[ip] || 0 33 | end 34 | end 35 | 36 | # Track a connection. Yields to a block; the connection is 37 | # tracked until the block returns. 38 | 39 | def track(socket) 40 | start_track socket 41 | begin 42 | yield 43 | ensure 44 | stop_track socket 45 | end 46 | end 47 | 48 | # Start tracking a connection 49 | 50 | def start_track(socket) 51 | @mutex.synchronize do 52 | ip = peer_ip(socket) 53 | @connections[ip] ||= 0 54 | @connections[ip] += 1 55 | @socket_ips[socket.object_id] = ip 56 | end 57 | rescue Errno::ENOTCONN 58 | end 59 | 60 | # Stop tracking a connection 61 | 62 | def stop_track(socket) 63 | @mutex.synchronize do 64 | ip = @socket_ips.delete(socket.object_id) 65 | break unless ip 66 | if (@connections[ip] -= 1) == 0 67 | @connections.delete(ip) 68 | end 69 | end 70 | end 71 | 72 | # Return the number of known IPs. This exists for the benefit of 73 | # the test, so that it can know the tracker has properly forgotten 74 | # about an IP with no connections. 75 | 76 | def known_ip_count 77 | @mutex.synchronize do 78 | @connections.size 79 | end 80 | end 81 | 82 | private 83 | 84 | end 85 | 86 | end 87 | -------------------------------------------------------------------------------- /lib/ftpd/data_server_factory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'data_server_factory/random_ephemeral_port' 4 | require_relative 'data_server_factory/specific_port_range' 5 | 6 | module Ftpd 7 | 8 | # Factories for creating TCPServer used for passive mode 9 | # connections. 10 | module DataServerFactory 11 | 12 | attr_reader :tcp_server 13 | 14 | # Create a factory. 15 | # 16 | # @param interface [String] The IP address of the interface to 17 | # bind to (e.g. "127.0.0.1") 18 | # @param ports [nil, Range] The range of ports to bind to. If nil, 19 | # then binds to a random ephemeral port. 20 | def self.make(interface, ports) 21 | if ports 22 | SpecificPortRange.new(interface, ports) 23 | else 24 | RandomEphemeralPort.new(interface) 25 | end 26 | end 27 | 28 | # @param interface [String] The IP address of the interface to 29 | # bind to (e.g. "127.0.0.1") 30 | # @param ports [nil, Range] The range of ports to bind to. If nil, 31 | # then binds to a random ephemeral port. 32 | def initialize(interface, ports) 33 | @interface = interface 34 | @ports = ports 35 | end 36 | 37 | # @return [TCPServer] 38 | def make_tcp_server 39 | TCPServer.new(@interface, 0) 40 | end 41 | 42 | end 43 | 44 | end 45 | -------------------------------------------------------------------------------- /lib/ftpd/data_server_factory/random_ephemeral_port.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | module DataServerFactory 6 | 7 | # Factory for creating TCPServer used for passive mode connections. 8 | # This factory binds to a random ephemeral port. 9 | class RandomEphemeralPort 10 | 11 | # @param interface [String] The IP address of the interface to 12 | # bind to (e.g. "127.0.0.1") 13 | def initialize(interface) 14 | @interface = interface 15 | end 16 | 17 | # @return [TCPServer] 18 | def make_tcp_server 19 | TCPServer.new(@interface, 0) 20 | end 21 | 22 | end 23 | 24 | end 25 | 26 | end 27 | -------------------------------------------------------------------------------- /lib/ftpd/data_server_factory/specific_port_range.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | module DataServerFactory 6 | 7 | # Factory for creating TCPServer used for passive mode 8 | # connections. This factory binds to a random port within a 9 | # specific range of ports. 10 | class SpecificPortRange 11 | 12 | # @param interface [String] The IP address of the interface to 13 | # bind to (e.g. "127.0.0.1") 14 | # @param ports [nil, Range] The range of ports to bind to. 15 | def initialize(interface, ports) 16 | @interface = interface 17 | @ports = ports 18 | end 19 | 20 | # @return [TCPServer] 21 | def make_tcp_server 22 | ports_to_try = @ports.to_a.shuffle 23 | until ports_to_try.empty? 24 | port = ports_to_try.shift 25 | begin 26 | return TCPServer.new(@interface, port) 27 | rescue Errno::EADDRINUSE 28 | end 29 | end 30 | TCPServer.new(@interface, 0) 31 | end 32 | 33 | end 34 | 35 | end 36 | 37 | end 38 | -------------------------------------------------------------------------------- /lib/ftpd/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | module Error 5 | 6 | def error(message, code) 7 | raise FtpServerError.new(message, code) 8 | end 9 | 10 | def unimplemented_error 11 | error "Command not implemented", 502 12 | end 13 | 14 | def sequence_error 15 | error "Bad sequence of commands", 503 16 | end 17 | 18 | def syntax_error 19 | error "Syntax error", 501 20 | end 21 | 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/ftpd/exception_translator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | # Translate specific exceptions to FileSystemError. 6 | # 7 | # This is not intended to be used directly, but via the 8 | # TranslateExceptions module. 9 | 10 | class ExceptionTranslator 11 | 12 | def initialize 13 | @exceptions = [] 14 | end 15 | 16 | # Register an exception class. 17 | 18 | def register_exception(e) 19 | @exceptions << e 20 | end 21 | 22 | # Run a block, translating specific exceptions to FileSystemError. 23 | 24 | def translate_exceptions 25 | begin 26 | return yield 27 | rescue *@exceptions => e 28 | raise PermanentFileSystemError, e.message 29 | end 30 | end 31 | 32 | end 33 | 34 | end 35 | -------------------------------------------------------------------------------- /lib/ftpd/exceptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | # All errors (purposefully) generated by this library driver from 6 | # this class. 7 | 8 | # Any error that send a reply to the client raises a FtpServerError. 9 | # The message is the text to send (e.g. "Syntax error") and the code 10 | # is the FTP response code to send (e.g. "502"). This is typically not 11 | # raised directly, but using the Error mixin. 12 | 13 | class FtpServerError < StandardError 14 | attr_reader :code 15 | 16 | def initialize(message, code) 17 | @code = code 18 | raise ArgumentError, "Invalid response code" unless valid_response_code? 19 | 20 | super(message) 21 | end 22 | 23 | def message_with_code 24 | "#{code} #{message}" 25 | end 26 | 27 | private 28 | def valid_response_code? 29 | (400..599).cover?(code) 30 | end 31 | end 32 | 33 | # A permanent file system error. The file isn't there, etc. 34 | 35 | class PermanentFileSystemError < FtpServerError 36 | def initialize(message, code = 550) 37 | super 38 | end 39 | 40 | private 41 | def valid_response_code? 42 | (550..559).cover?(code) 43 | end 44 | end 45 | 46 | # A transient file system error. The file is busy, etc. 47 | 48 | class TransientFileSystemError < FtpServerError 49 | def initialize(message, code = 450) 50 | super 51 | end 52 | 53 | private 54 | def valid_response_code? 55 | (450..459).cover?(code) 56 | end 57 | end 58 | 59 | # A permanent file system error. Deprecated; use 60 | # PermanentFileSystemError instead. 61 | 62 | class FileSystemError < PermanentFileSystemError ; end 63 | 64 | end 65 | -------------------------------------------------------------------------------- /lib/ftpd/file_system_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | module FileSystemHelper 6 | 7 | def path_list(path) 8 | if file_system.directory?(path) 9 | path = File.join(path, '*') 10 | end 11 | file_system.dir(path).sort 12 | end 13 | 14 | def ensure_file_system_supports(method) 15 | unless file_system.respond_to?(method) 16 | unimplemented_error 17 | end 18 | end 19 | 20 | def ensure_accessible(path) 21 | unless file_system.accessible?(path) 22 | error 'Access denied', 550 23 | end 24 | end 25 | 26 | def ensure_exists(path) 27 | unless file_system.exists?(path) 28 | error 'No such file or directory', 550 29 | end 30 | end 31 | 32 | def ensure_does_not_exist(path) 33 | if file_system.exists?(path) 34 | error 'Already exists', 550 35 | end 36 | end 37 | 38 | def ensure_directory(path) 39 | unless file_system.directory?(path) 40 | error 'Not a directory', 550 41 | end 42 | end 43 | 44 | def unique_path(path) 45 | suffix = nil 46 | 100.times do 47 | path_with_suffix = [path, suffix].compact.join('.') 48 | unless file_system.exists?(path_with_suffix) 49 | return path_with_suffix 50 | end 51 | suffix = generate_suffix 52 | end 53 | raise "Unable to find unique path" 54 | end 55 | 56 | private 57 | 58 | def generate_suffix 59 | set = ('a'..'z').to_a 60 | 8.times.map do 61 | set[rand(set.size)] 62 | end.join 63 | end 64 | 65 | end 66 | 67 | end 68 | -------------------------------------------------------------------------------- /lib/ftpd/gets_peer_address.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | module GetsPeerAddress 6 | 7 | # Obtain the IP that the client connected _from_. 8 | # 9 | # How this is done depends upon which type of socket (SSL or not) 10 | # and what version of Ruby. 11 | # 12 | # * SSL socket 13 | # * #peeraddr. Uses BasicSocket.do_not_reverse_lookup. 14 | # * Ruby 1.8.7 15 | # * #peeraddr, which does not take the "reverse lookup" 16 | # argument, relying instead using 17 | # BasicSocket.do_not_reverse_lookup. 18 | # * #getpeername, which does not do a reverse lookup. It is a 19 | # little uglier than #peeraddr. 20 | # * Ruby >=1.9.3 21 | # * #peeraddr, which takes the "reverse lookup" argument. 22 | # * #getpeername - same as 1.8.7 23 | # 24 | # @return [String] IP address 25 | 26 | def peer_ip(socket) 27 | if socket.respond_to?(:getpeername) 28 | # Non SSL 29 | sockaddr = socket.getpeername 30 | _port, host = Socket.unpack_sockaddr_in(sockaddr) 31 | host 32 | else 33 | # SSL 34 | BasicSocket.do_not_reverse_lookup = true 35 | socket.peeraddr.last 36 | end 37 | end 38 | 39 | end 40 | 41 | end 42 | -------------------------------------------------------------------------------- /lib/ftpd/insecure_certificate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | # This mixin provides an insecure SSL certificate. This certificate 6 | # should only be used for testing. 7 | 8 | module InsecureCertificate 9 | 10 | # The path of an insecure SSL certificate. 11 | 12 | def insecure_certfile_path 13 | File.expand_path('../../insecure-test-cert.pem', 14 | File.dirname(__FILE__)) 15 | end 16 | 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/ftpd/list_format/eplf.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | module ListFormat 5 | 6 | # Easily Parsed LIST Format (EPLF) Directory formatter 7 | # See: {http://cr.yp.to/ftp/list/eplf.html} 8 | 9 | class Eplf 10 | 11 | extend Forwardable 12 | 13 | # Create a new formatter for a file object 14 | # @param file_info [FileInfo] 15 | 16 | def initialize(file_info) 17 | @file_info = file_info 18 | end 19 | 20 | # Return the formatted directory entry. 21 | # For example: 22 | # +i8388621.48598,m824253270,r,s612, 514.html 23 | # Note: The calling code adds the \r\n 24 | 25 | def to_s 26 | "+%s\t%s" % [facts, filename] 27 | end 28 | 29 | private 30 | 31 | def facts 32 | [ 33 | retrievable_fact, 34 | cwd_target_fact, 35 | size_fact, 36 | mtime_fact, 37 | identifier_fact, 38 | ].compact.join(',') 39 | end 40 | 41 | def retrievable_fact 42 | 'r' if retrievable? 43 | end 44 | 45 | def cwd_target_fact 46 | '/' if cwd_target? 47 | end 48 | 49 | def size_fact 50 | "s#{@file_info.size}" if retrievable? 51 | end 52 | 53 | def mtime_fact 54 | "m#{@file_info.mtime.to_i}" 55 | end 56 | 57 | def identifier_fact 58 | "i#{@file_info.identifier}" if @file_info.identifier 59 | end 60 | 61 | def filename 62 | File.basename(@file_info.path) 63 | end 64 | 65 | def retrievable? 66 | @file_info.file? 67 | end 68 | 69 | def cwd_target? 70 | @file_info.directory? 71 | end 72 | 73 | end 74 | 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/ftpd/list_path.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | # Functions for manipulating LIST and NLST arguments 6 | 7 | module ListPath 8 | 9 | # Turn the argument to LIST/NLST into a path 10 | # 11 | # @param argument [String] The argument, or nil if not present 12 | # @return [String] The path 13 | # 14 | # Although compliant with the spec, this function does not do 15 | # these things that traditional Unix FTP servers do: 16 | # 17 | # * Allow multiple paths 18 | # * Handle switches such as "-a" 19 | # 20 | # See: http://cr.yp.to/ftp/list.html sections "LIST parameters" 21 | # and "LIST wildcards" 22 | 23 | def list_path(argument) 24 | argument ||= '.' 25 | argument = '' if argument =~ /^-/ 26 | argument 27 | end 28 | 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/ftpd/null_logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | # A logger that does not log. 6 | # Quacks enough like a Logger to fool Ftpd. 7 | 8 | class NullLogger 9 | 10 | def self.stub(method_name) 11 | define_method method_name do |*args| 12 | end 13 | end 14 | 15 | stub :unknown 16 | stub :fatal 17 | stub :error 18 | stub :warn 19 | stub :info 20 | stub :debug 21 | 22 | end 23 | 24 | end 25 | -------------------------------------------------------------------------------- /lib/ftpd/protocols.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | # With the commands EPORT and EPSV, the client sends a protocol code 6 | # to indicate whether it wants an IPV4 or an IPV6 connection. This 7 | # class contains functions related to that protocol code. 8 | 9 | class Protocols 10 | 11 | module Codes 12 | IPV4 = 1 13 | IPV6 = 2 14 | end 15 | include Codes 16 | 17 | # @param socket [TCPSocket, OpenSSL::SSL::SSLSocket] The socket. 18 | # It doesn't matter whether it's the server socket (the one on 19 | # which #accept is called), or the socket returned by #accept. 20 | 21 | def initialize(socket) 22 | @socket = socket 23 | end 24 | 25 | # Can the socket support a connection in the indicated protocol? 26 | # 27 | # @param protocol_code [Integer] protocol code 28 | 29 | def supports_protocol?(protocol_code) 30 | protocol_codes.include?(protocol_code) 31 | end 32 | 33 | # What protocol codes does the socket support? 34 | # 35 | # @return [Array] List of protocol codes 36 | 37 | def protocol_codes 38 | [ 39 | (IPV4 if supports_ipv4?), 40 | (IPV6 if supports_ipv6?), 41 | ].compact 42 | end 43 | 44 | private 45 | 46 | def supports_ipv4? 47 | @socket.local_address.ipv4? || ipv6_dual_stack? 48 | end 49 | 50 | def supports_ipv6? 51 | @socket.local_address.ipv6? 52 | end 53 | 54 | def ipv6_dual_stack? 55 | v6only = @socket.getsockopt(Socket::IPPROTO_IPV6, 56 | Socket::IPV6_V6ONLY).unpack('i') 57 | v6only == [0] 58 | end 59 | 60 | end 61 | 62 | end 63 | -------------------------------------------------------------------------------- /lib/ftpd/release.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | # Release information. This is used both by the library and by the 6 | # gemspec. 7 | module Release 8 | 9 | VERSION = "2.1.0" 10 | DATE = "2017-07-23" 11 | 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/ftpd/stream.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | class Stream 6 | 7 | CHUNK_SIZE = 1024 * 100 # 100kb 8 | 9 | attr_reader :data_type 10 | attr_reader :byte_count 11 | 12 | # @param io [IO] The stream to read from or write to 13 | # @param data_type [String] The FTP data type of the stream 14 | 15 | def initialize(io, data_type) 16 | @io, @data_type = io, data_type 17 | @byte_count = 0 18 | end 19 | 20 | # Read and convert a chunk of up to CHUNK_SIZE from the stream 21 | # @return [String] if any bytes remain to read from the stream 22 | # @return [NilClass] if no bytes remain 23 | 24 | def read 25 | chunk = converted_chunk(@io) 26 | return unless chunk 27 | chunk = nvt_ascii_to_unix(chunk) if data_type == 'A' 28 | record_bytes(chunk) 29 | chunk 30 | end 31 | 32 | # Convert and write a chunk of up to CHUNK_SIZE to the stream from the 33 | # provided IO object 34 | # 35 | # @param io [IO] The data to be written to the stream 36 | 37 | def write(io) 38 | while chunk = converted_chunk(io) 39 | chunk = unix_to_nvt_ascii(chunk) if data_type == 'A' 40 | result = @io.write(chunk) 41 | record_bytes(chunk) 42 | result 43 | end 44 | end 45 | 46 | private 47 | 48 | # We never want to break up any \r\n sequences in the file. To avoid 49 | # this in an efficient way, we always pull an "extra" character from the 50 | # stream and add it to the buffer. If the character is a \r, then we put 51 | # it back onto the stream instead of adding it to the buffer. 52 | 53 | def converted_chunk(io) 54 | chunk = io.read(CHUNK_SIZE) 55 | return unless chunk 56 | if data_type == 'A' 57 | next_char = io.getc 58 | if next_char == "\r" 59 | io.ungetc(next_char) 60 | elsif next_char 61 | chunk += next_char 62 | end 63 | end 64 | chunk 65 | end 66 | 67 | def unix_to_nvt_ascii(s) 68 | return s if s =~ /\r\n/ 69 | s.gsub(/\n/, "\r\n") 70 | end 71 | 72 | def nvt_ascii_to_unix(s) 73 | s.gsub(/\r\n/, "\n") 74 | end 75 | 76 | def record_bytes(chunk) 77 | @byte_count += chunk.size if chunk 78 | end 79 | 80 | end 81 | 82 | end 83 | -------------------------------------------------------------------------------- /lib/ftpd/temp_dir.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | # Create temporary directories that will be removed when the program 6 | # exits. 7 | 8 | module TempDir 9 | 10 | # Create a temporary directory, returning its path. When the 11 | # program exists, the directory (and its contents) are removed. 12 | 13 | def make 14 | Dir.mktmpdir.tap do |path| 15 | at_exit do 16 | FileUtils.rm_rf path 17 | Dir.rmdir(path) if File.exist?(path) 18 | end 19 | end 20 | end 21 | module_function :make 22 | 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/ftpd/tls_server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'server' 4 | 5 | module Ftpd 6 | class TlsServer < Server 7 | 8 | # Whether or not to do TLS, and which flavor. 9 | # 10 | # One of: 11 | # * :off 12 | # * :explicit 13 | # * :implicit 14 | # 15 | # Notes: 16 | # * Defaults to :off 17 | # * Set this before calling #start. 18 | # * If other than :off, then #certfile_path must be set. 19 | # 20 | # @return [Symbol] 21 | 22 | attr_accessor :tls 23 | 24 | # The path of the SSL certificate to use for TLS. Defaults to nil 25 | # (no SSL certificate). 26 | # 27 | # Set this before calling #start. 28 | # 29 | # @return [String] 30 | 31 | attr_accessor :certfile_path 32 | 33 | # Create a new TLS server. 34 | 35 | def initialize 36 | super 37 | @tls = :off 38 | end 39 | 40 | private 41 | 42 | def make_server_socket 43 | socket = super 44 | if tls_enabled? 45 | socket = OpenSSL::SSL::SSLServer.new(socket, ssl_context) 46 | socket.start_immediately = false 47 | end 48 | socket 49 | end 50 | 51 | def accept 52 | socket = @server_socket.accept 53 | if tls_enabled? 54 | add_missing_methods_to_socket socket 55 | add_tls_methods_to_socket socket 56 | end 57 | socket 58 | end 59 | 60 | def ssl_context 61 | unless @certfile_path 62 | raise ArgumentError, ":certfile required if tls enabled" 63 | end 64 | context = OpenSSL::SSL::SSLContext.new 65 | File.open(@certfile_path) do |certfile| 66 | context.cert = OpenSSL::X509::Certificate.new(certfile) 67 | certfile.rewind 68 | context.key = OpenSSL::PKey::RSA.new(certfile) 69 | end 70 | context 71 | end 72 | memoize :ssl_context 73 | 74 | # Add to the TLS socket some methods that regular socket has, but TLS socket is missing 75 | 76 | def add_missing_methods_to_socket(socket) 77 | 78 | def socket.local_address 79 | @io.local_address 80 | end 81 | 82 | end 83 | 84 | # Add to the TLS sockets some methods of our own invention that 85 | # make life a little easier. 86 | 87 | def add_tls_methods_to_socket(socket) 88 | class << socket 89 | 90 | def ssl_context 91 | context 92 | end 93 | 94 | def encrypted? 95 | !!cipher 96 | end 97 | 98 | def encrypt 99 | accept 100 | end 101 | 102 | end 103 | end 104 | 105 | private 106 | 107 | def tls_enabled? 108 | @tls != :off 109 | end 110 | 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/ftpd/translate_exceptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | # This module translates exceptions to FileSystemError exceptions. 6 | # 7 | # A disk file system (such as Ftpd::DiskFileSystem) is expected to 8 | # raise only FileSystemError exceptions, but many common operations 9 | # result in other exceptions such as SystemCallError. This module 10 | # aids a disk driver in translating exceptions to FileSystemError 11 | # exceptions. 12 | # 13 | # In your file system, driver, include this module: 14 | # 15 | # module MyDiskDriver 16 | # include Ftpd::TranslateExceptions 17 | # 18 | # in your constructor, register the exceptions that should be translated: 19 | # 20 | # def initialize 21 | # translate_exception SystemCallError 22 | # end 23 | # 24 | # And register methods for translation: 25 | # 26 | # def read(ftp_path) 27 | # ... 28 | # end 29 | # translate_exceptions :read 30 | 31 | module TranslateExceptions 32 | 33 | include Memoizer 34 | 35 | def self.included(includer) 36 | includer.extend ClassMethods 37 | end 38 | 39 | module ClassMethods 40 | 41 | # Cause the named method to translate exceptions. 42 | 43 | def translate_exceptions(method_name) 44 | original_method = instance_method(method_name) 45 | remove_method(method_name) 46 | define_method(method_name) do |*args, &block| 47 | exception_translator.translate_exceptions do 48 | original_method.bind(self).call(*args, &block) 49 | end 50 | end 51 | end 52 | 53 | end 54 | 55 | # Add exception class e to the list of exceptions to be 56 | # translated. 57 | 58 | def translate_exception(e) 59 | exception_translator.register_exception e 60 | end 61 | 62 | private 63 | 64 | def exception_translator 65 | ExceptionTranslator.new 66 | end 67 | memoize :exception_translator 68 | 69 | end 70 | 71 | end 72 | -------------------------------------------------------------------------------- /rake_tasks/cucumber.rake: -------------------------------------------------------------------------------- 1 | require 'cucumber/rake/task' 2 | 3 | Cucumber::Rake::Task.new 'test:features' do |t| 4 | t.fork = true 5 | t.cucumber_opts = '--format progress' 6 | end 7 | 8 | task 'test:cucumber' => ['test:features'] 9 | task 'cucumber' => ['test:features'] 10 | -------------------------------------------------------------------------------- /rake_tasks/default.rake: -------------------------------------------------------------------------------- 1 | task :default => [:test] 2 | -------------------------------------------------------------------------------- /rake_tasks/spec.rake: -------------------------------------------------------------------------------- 1 | require "bundler" 2 | Bundler.setup(:test) 3 | require 'rspec/core/rake_task' 4 | RSpec::Core::RakeTask.new 'test:spec' 5 | task :spec => ['test:spec'] 6 | -------------------------------------------------------------------------------- /rake_tasks/test.rake: -------------------------------------------------------------------------------- 1 | desc 'Run all tests' 2 | task :test => ['test:spec', 'test:cucumber'] 3 | -------------------------------------------------------------------------------- /rake_tasks/yard.rake: -------------------------------------------------------------------------------- 1 | if Gem::Specification::find_all_by_name("yard").any? 2 | 3 | require 'yard' 4 | YARD::Rake::YardocTask.new do |t| 5 | end 6 | 7 | end 8 | -------------------------------------------------------------------------------- /spec/command_sequence_checker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | describe CommandSequenceChecker do 5 | 6 | let(:sequence_error_verification) do 7 | lambda {|e| e.code == 503 && e.message == "Bad sequence of commands"} 8 | end 9 | subject(:checker) {CommandSequenceChecker.new} 10 | 11 | context 'initial' do 12 | 13 | it 'accepts any command' do 14 | checker.check 'NOOP' 15 | end 16 | 17 | end 18 | 19 | context 'when a specific command is expected' do 20 | 21 | before(:each) {checker.expect 'PASS'} 22 | 23 | it 'accepts that command' do 24 | checker.check 'PASS' 25 | end 26 | 27 | it 'rejects any other command' do 28 | expect { 29 | checker.check 'NOOP' 30 | }.to raise_error(FtpServerError, &sequence_error_verification) 31 | end 32 | 33 | end 34 | 35 | context 'after the expected command has arrived' do 36 | 37 | before(:each) do 38 | checker.expect 'PASS' 39 | checker.check 'PASS' 40 | end 41 | 42 | it 'accepts any other command' do 43 | checker.check 'NOOP' 44 | end 45 | 46 | end 47 | 48 | context 'after a command is rejected' do 49 | 50 | before(:each) do 51 | checker.expect 'PASS' 52 | expect { 53 | checker.check 'NOOP' 54 | }.to raise_error(FtpServerError, &sequence_error_verification) 55 | end 56 | 57 | it 'accepts any other command' do 58 | checker.check 'NOOP' 59 | end 60 | 61 | end 62 | 63 | context 'when a command must be expected' do 64 | 65 | before(:each) do 66 | checker.must_expect 'PASS' 67 | end 68 | 69 | it 'rejects that command if not expected' do 70 | expect { 71 | checker.check 'PASS' 72 | }.to raise_error(FtpServerError, &sequence_error_verification) 73 | end 74 | 75 | it 'accepts that command when it is accepted' do 76 | checker.expect 'PASS' 77 | checker.check 'PASS' 78 | end 79 | 80 | end 81 | 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/connection_throttle_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | describe ConnectionThrottle do 6 | 7 | let(:socket) {double TCPSocket} 8 | let(:connections) {0} 9 | let(:connections_for_socket) {0} 10 | let(:connection_tracker) do 11 | double ConnectionTracker, :connections => connections 12 | end 13 | subject(:connection_throttle) do 14 | ConnectionThrottle.new(connection_tracker) 15 | end 16 | 17 | before(:each) do 18 | allow(connection_tracker).to receive(:connections) 19 | .and_return(connections) 20 | allow(connection_tracker).to receive(:connections_for) 21 | .with(socket) 22 | .and_return(connections_for_socket) 23 | end 24 | 25 | it 'should have defaults' do 26 | expect(connection_throttle.max_connections).to be_nil 27 | expect(connection_throttle.max_connections_per_ip).to be_nil 28 | end 29 | 30 | describe '#allow?' do 31 | 32 | context '(total connections)' do 33 | 34 | let(:max_connections) {50} 35 | 36 | before(:each) do 37 | connection_throttle.max_connections = max_connections 38 | connection_throttle.max_connections_per_ip = 2 * max_connections 39 | end 40 | 41 | context 'almost at maximum connections' do 42 | let(:connections) {max_connections - 1} 43 | specify {expect(connection_throttle.allow?(socket)).to be_truthy} 44 | end 45 | 46 | context 'at maximum connections' do 47 | let(:connections) {max_connections} 48 | specify {expect(connection_throttle.allow?(socket)).to be_falsey} 49 | end 50 | 51 | context 'above maximum connections' do 52 | let(:connections) {max_connections + 1} 53 | specify {expect(connection_throttle.allow?(socket)).to be_falsey} 54 | end 55 | 56 | end 57 | 58 | context '(per ip)' do 59 | 60 | let(:max_connections_per_ip) {5} 61 | 62 | before(:each) do 63 | connection_throttle.max_connections = 2 * max_connections_per_ip 64 | connection_throttle.max_connections_per_ip = max_connections_per_ip 65 | end 66 | 67 | context 'almost at maximum connections for ip' do 68 | let(:connections_for_socket) {max_connections_per_ip - 1} 69 | specify {expect(connection_throttle.allow?(socket)).to be_truthy} 70 | end 71 | 72 | context 'at maximum connections for ip' do 73 | let(:connections_for_socket) {max_connections_per_ip} 74 | specify {expect(connection_throttle.allow?(socket)).to be_falsey} 75 | end 76 | 77 | context 'above maximum connections for ip' do 78 | let(:connections_for_socket) {max_connections_per_ip + 1} 79 | specify {expect(connection_throttle.allow?(socket)).to be_falsey} 80 | end 81 | 82 | end 83 | 84 | end 85 | 86 | describe '#deny' do 87 | 88 | let(:socket) {StringIO.new} 89 | 90 | it 'should send a "too many connections" message' do 91 | connection_throttle.deny socket 92 | expect(socket.string).to eq "421 Too many connections\r\n" 93 | end 94 | 95 | end 96 | 97 | end 98 | 99 | end 100 | -------------------------------------------------------------------------------- /spec/connection_tracker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | describe ConnectionTracker do 6 | 7 | before(:all) do 8 | Thread.abort_on_exception = true 9 | end 10 | 11 | # Create a mock socket with the given peer address 12 | 13 | def socket_bound_to(source_ip) 14 | socket = double TCPSocket 15 | peeraddr = Socket.pack_sockaddr_in(0, source_ip) 16 | allow(socket).to receive(:getpeername) {peeraddr} 17 | socket 18 | end 19 | 20 | subject(:connection_tracker) {ConnectionTracker.new} 21 | 22 | describe '#connections' do 23 | 24 | let(:socket) {socket_bound_to('127.0.0.1')} 25 | 26 | context '(session ends normally)' do 27 | 28 | it 'should track the total number of connection' do 29 | expect(connection_tracker.connections).to eq 0 30 | connection_tracker.start_track socket 31 | expect(connection_tracker.connections).to eq 1 32 | connection_tracker.stop_track socket 33 | expect(connection_tracker.connections).to eq 0 34 | end 35 | 36 | end 37 | 38 | end 39 | 40 | describe '#connections_for' do 41 | 42 | it 'should track the number of connections for an ip' do 43 | socket1 = socket_bound_to('127.0.0.1') 44 | socket2 = socket_bound_to('127.0.0.2') 45 | expect(connection_tracker.connections_for(socket1)).to eq 0 46 | expect(connection_tracker.connections_for(socket2)).to eq 0 47 | connection_tracker.start_track socket1 48 | expect(connection_tracker.connections_for(socket1)).to eq 1 49 | expect(connection_tracker.connections_for(socket2)).to eq 0 50 | connection_tracker.stop_track socket1 51 | expect(connection_tracker.connections_for(socket1)).to eq 0 52 | expect(connection_tracker.connections_for(socket2)).to eq 0 53 | end 54 | 55 | end 56 | 57 | describe '#known_ip_count' do 58 | 59 | let(:socket) {socket_bound_to('127.0.0.1')} 60 | 61 | it 'should forget about an IP that has no connection' do 62 | expect(connection_tracker.known_ip_count).to eq 0 63 | connection_tracker.start_track socket 64 | expect(connection_tracker.known_ip_count).to eq 1 65 | connection_tracker.stop_track socket 66 | expect(connection_tracker.known_ip_count).to eq 0 67 | end 68 | 69 | end 70 | 71 | describe '#track' do 72 | 73 | let(:socket) {socket_bound_to('127.0.0.1')} 74 | 75 | context '(session ends normally)' do 76 | specify do 77 | expect(connection_tracker.connections_for(socket)).to eq 0 78 | connection_tracker.track(socket) do 79 | expect(connection_tracker.connections_for(socket)).to eq 1 80 | end 81 | expect(connection_tracker.connections_for(socket)).to eq 0 82 | end 83 | end 84 | 85 | context '(session ends with exception)' do 86 | specify do 87 | expect(connection_tracker.connections_for(socket)).to eq 0 88 | connection_tracker.track(socket) { raise } rescue 89 | expect(connection_tracker.connections_for(socket)).to eq 0 90 | end 91 | end 92 | 93 | end 94 | 95 | end 96 | 97 | end 98 | -------------------------------------------------------------------------------- /spec/data_server_factory_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | describe DataServerFactory do 5 | 6 | it "creates a socket bound to 127.0.0.1" do 7 | factory = DataServerFactory.make("127.0.0.1", nil) 8 | tcp_server = factory.make_tcp_server 9 | expect(tcp_server.addr[3]).to eq "127.0.0.1" 10 | end 11 | 12 | it "creates a socket bound to 127.0.0.2" do 13 | factory = DataServerFactory.make("127.0.0.2", nil) 14 | tcp_server = factory.make_tcp_server 15 | expect(tcp_server.addr[3]).to eq "127.0.0.2" 16 | end 17 | 18 | context "with no port range" do 19 | 20 | it "creates a socket bound to an ephemeral port" do 21 | interface = "0.0.0.0" 22 | factory = DataServerFactory.make(interface, nil) 23 | ports = (1..10).map do 24 | tcp_server = factory.make_tcp_server 25 | begin 26 | tcp_server.addr[1] 27 | ensure 28 | tcp_server.close 29 | end 30 | end 31 | expect(ports.uniq.size).to be > 1 32 | ports.each do |port| 33 | expect(port).to be_between(1024, 65535) 34 | end 35 | end 36 | 37 | end 38 | 39 | context "with a port range" do 40 | 41 | let(:interface) { "127.0.0.1" } 42 | 43 | def get_unused_port 44 | server = TCPServer.new(interface, 0) 45 | port = server.addr[1] 46 | server.close 47 | port 48 | end 49 | 50 | def use_port(port) 51 | server = TCPServer.new(interface, port) 52 | begin 53 | yield 54 | ensure 55 | server.close 56 | end 57 | end 58 | 59 | it "creates a socket bound to an ephemeral port" do 60 | ports = (1..10).map { get_unused_port } 61 | factory = DataServerFactory.make(interface, ports) 62 | 10.times do 63 | tcp_server = factory.make_tcp_server 64 | begin 65 | port = tcp_server.addr[1] 66 | expect(ports).to include(port) 67 | ensure 68 | tcp_server.close 69 | end 70 | end 71 | end 72 | 73 | it "skips a port that is already in use" do 74 | ports = (1..2).map { get_unused_port } 75 | use_port(ports[0]) do 76 | factory = DataServerFactory.make(interface, ports) 77 | 10.times do 78 | tcp_server = factory.make_tcp_server 79 | begin 80 | port = tcp_server.addr[1] 81 | expect(port).to eq ports[1] 82 | ensure 83 | tcp_server.close 84 | end 85 | end 86 | end 87 | end 88 | 89 | it "uses a random ephemeral port when all configured ports are in use" do 90 | ports = [ get_unused_port ] 91 | use_port(ports[0]) do 92 | factory = DataServerFactory.make(interface, ports) 93 | tcp_server = factory.make_tcp_server 94 | port = tcp_server.addr[1] 95 | expect(port).to_not eq ports[0] 96 | end 97 | end 98 | 99 | end 100 | 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /spec/exception_translator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | describe ExceptionTranslator do 5 | 6 | class FooError < StandardError ; end 7 | class BarError < StandardError ; end 8 | 9 | subject(:translator) {ExceptionTranslator.new} 10 | let(:message) {'An error happened'} 11 | 12 | context '(registered exception)' do 13 | before(:each) do 14 | translator.register_exception FooError 15 | end 16 | it 'should translate the exception' do 17 | expect { 18 | subject.translate_exceptions do 19 | raise FooError, message 20 | end 21 | }.to raise_error PermanentFileSystemError, message 22 | end 23 | end 24 | 25 | context '(unregistered exception)' do 26 | it 'should pass the exception' do 27 | expect { 28 | subject.translate_exceptions do 29 | raise BarError, message 30 | end 31 | }.to raise_error BarError, message 32 | end 33 | end 34 | 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/file_info_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | describe FileInfo do 5 | 6 | subject {FileInfo.new(opts)} 7 | 8 | def self.it_has_attribute(attribute) 9 | describe "##{attribute}" do 10 | let(:value) {"#{attribute} value"} 11 | let(:opts) {{attribute => value}} 12 | its(attribute) {should == value} 13 | end 14 | end 15 | 16 | it_has_attribute :ftype 17 | it_has_attribute :group 18 | it_has_attribute :identifier 19 | it_has_attribute :mode 20 | it_has_attribute :mtime 21 | it_has_attribute :nlink 22 | it_has_attribute :owner 23 | it_has_attribute :path 24 | it_has_attribute :size 25 | 26 | describe '#file?' do 27 | 28 | let(:opts) {{:ftype => ftype}} 29 | 30 | context '(file)' do 31 | let(:ftype) {'file'} 32 | its(:file?) {should be_truthy} 33 | end 34 | 35 | context '(directory)' do 36 | let(:ftype) {'directory'} 37 | its(:file?) {should be_falsey} 38 | end 39 | 40 | end 41 | 42 | describe '#directory?' do 43 | 44 | let(:opts) {{:ftype => ftype}} 45 | 46 | context '(file)' do 47 | let(:ftype) {'file'} 48 | its(:directory?) {should be_falsey} 49 | end 50 | 51 | context '(directory)' do 52 | let(:ftype) {'directory'} 53 | its(:directory?) {should be_truthy} 54 | end 55 | 56 | end 57 | 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/ftp_server_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | describe FtpServerError do 5 | 6 | it "won't instantiate with an invalid error code" do 7 | expect { described_class.new("Nooooooooo", 665) }.to( 8 | raise_error(ArgumentError, "Invalid response code") 9 | ) 10 | end 11 | 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/list_format/eplf_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | module ListFormat 5 | describe Eplf do 6 | 7 | context '(file)' do 8 | 9 | let(:file_info) do 10 | FileInfo.new(:ftype => 'file', 11 | :mode => 0100644, 12 | :mtime => Time.utc(2013, 3, 3, 8, 38, 0), 13 | :path => 'foo', 14 | :size => 1234) 15 | end 16 | subject(:formatter) {Eplf.new(file_info)} 17 | 18 | it 'should produce EPLF format' do 19 | expect(formatter.to_s).to eq "+r,s1234,m1362299880\tfoo" 20 | end 21 | 22 | end 23 | 24 | context '(directory)' do 25 | 26 | let(:file_info) do 27 | FileInfo.new(:ftype => 'directory', 28 | :mode => 0100644, 29 | :mtime => Time.utc(2013, 3, 3, 8, 38, 0), 30 | :path => 'foo', 31 | :size => 1024) 32 | end 33 | subject(:formatter) {Eplf.new(file_info)} 34 | 35 | it 'should produce EPLF format' do 36 | expect(formatter.to_s).to eq "+/,m1362299880\tfoo" 37 | end 38 | 39 | end 40 | 41 | context '(with identifier)' do 42 | 43 | let(:file_info) do 44 | FileInfo.new(:ftype => 'file', 45 | :mode => 0100644, 46 | :mtime => Time.utc(2013, 3, 3, 8, 38, 0), 47 | :path => 'foo', 48 | :identifier => '1234.5678', 49 | :size => 1234) 50 | end 51 | subject(:formatter) {Eplf.new(file_info)} 52 | 53 | it 'should produce EPLF format' do 54 | expect(formatter.to_s).to eq "+r,s1234,m1362299880,i1234.5678\tfoo" 55 | end 56 | 57 | end 58 | 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/list_path_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | describe ListPath do 5 | 6 | include ListPath 7 | 8 | it 'should replace a missing path with "."' do 9 | expect(list_path(nil)).to eq('.') 10 | end 11 | 12 | it 'should replace a switch with nothing' do 13 | expect(list_path('-a')).to eq('') 14 | end 15 | 16 | it 'should preserve a filename with a dash in it' do 17 | expect(list_path('foo-bar')).to eq('foo-bar') 18 | end 19 | 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/null_logger_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | describe NullLogger do 5 | 6 | subject {NullLogger.new} 7 | 8 | def self.should_stub(method) 9 | describe "#{method}" do 10 | specify do 11 | expect(subject).to respond_to method 12 | end 13 | end 14 | end 15 | 16 | should_stub :unknown 17 | should_stub :fatal 18 | should_stub :error 19 | should_stub :warn 20 | should_stub :info 21 | should_stub :debug 22 | 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/server_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | 5 | describe Server do 6 | 7 | describe '#join' do 8 | it 'calls server_thread#join' do 9 | expect_any_instance_of(Thread).to receive(:join) 10 | server = Server.new 11 | server.start 12 | server.join 13 | end 14 | context 'when server is not started' do 15 | it 'raises an error' do 16 | server = Server.new 17 | expect { server.join }.to raise_error('Server is not started!') 18 | end 19 | end 20 | end 21 | 22 | describe 'reuse explicit port (github #23)' do 23 | 24 | # The bug being tested involves a race condition. Monkey patch 25 | # the server so that start does not return until "accept" has 26 | # been called on the server socket. This causes the test to 27 | # reliably expose the bug. 28 | 29 | def monkey_patch_server(server) 30 | 31 | class << server 32 | 33 | def start 34 | @accepting = Queue.new 35 | super 36 | wait_for_accept 37 | end 38 | 39 | def accept 40 | @accepting.enq true 41 | super 42 | end 43 | 44 | private 45 | 46 | def wait_for_accept 47 | @accepting.deq 48 | # There's a potential race condition in _this_ code: 49 | # @accepting is triggered just before the accept is done 50 | # on the socket, but it's possible that the server thread 51 | # has been preempted and accept has not yet been called. 52 | # A little sleep gives the server thread another shot at 53 | # actually getting the accept done before we continue. 54 | sleep 0.01 55 | end 56 | 57 | end 58 | 59 | end 60 | 61 | it do 62 | port = find_open_port 63 | 2.times do 64 | server = Server.new 65 | monkey_patch_server server 66 | server.port = port 67 | server.start 68 | server.stop 69 | end 70 | end 71 | 72 | def find_open_port 73 | socket = TCPServer.new('localhost', 0) 74 | socket.addr[1].tap {socket.close} 75 | end 76 | 77 | end 78 | 79 | end 80 | 81 | end 82 | -------------------------------------------------------------------------------- /spec/telnet_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # -*- ruby encoding: us-ascii -*- 4 | 5 | module Ftpd 6 | describe Telnet do 7 | 8 | IAC = 255.chr # 0xff 9 | DONT = 254.chr # 0xfe 10 | DO = 253.chr # 0xfd 11 | WONT = 252.chr # 0xfc 12 | WILL = 251.chr # 0xfb 13 | IP = 244.chr # 0xf4 14 | DM = 242.chr # 0xf2 15 | 16 | subject {Telnet.new(command)} 17 | let(:plain_command) {"NOOP\r\n"} 18 | let(:command) {codes + plain_command} 19 | 20 | context '(plain command)' do 21 | let(:codes) {''} 22 | its(:reply) {should == ''} 23 | its(:plain) {should == plain_command} 24 | end 25 | 26 | context '(escaped IAC)' do 27 | let(:codes) {"#{IAC}#{IAC}"} 28 | its(:reply) {should == ''} 29 | its(:plain) {should == "#{IAC}" + plain_command} 30 | end 31 | 32 | context '(IAC + unknown code)' do 33 | let(:codes) {"#{IAC}\x01"} 34 | its(:reply) {should == ''} 35 | its(:plain) {should == codes + plain_command} 36 | end 37 | 38 | context '(WILL)' do 39 | let(:codes) {"#{IAC}#{WILL}\x01"} 40 | its(:reply) {should == "#{IAC}#{DONT}\x01"} 41 | its(:plain) {should == plain_command} 42 | end 43 | 44 | context '(WONT)' do 45 | let(:codes) {"#{IAC}#{WONT}\x01"} 46 | its(:reply) {should == ''} 47 | its(:plain) {should == plain_command} 48 | end 49 | 50 | context '(DO)' do 51 | let(:codes) {"#{IAC}#{DO}\x01"} 52 | its(:reply) {should == "#{IAC}#{WONT}\x01"} 53 | its(:plain) {should == plain_command} 54 | end 55 | 56 | context '(DONT)' do 57 | let(:codes) {"#{IAC}#{DONT}\x01"} 58 | its(:reply) {should == ''} 59 | its(:plain) {should == plain_command} 60 | end 61 | 62 | context '(interrupt process)' do 63 | let(:codes) {"#{IAC}#{IP}"} 64 | its(:reply) {should == ''} 65 | its(:plain) {should == plain_command} 66 | end 67 | 68 | context '(data mark)' do 69 | let(:codes) {"#{IAC}#{DM}"} 70 | its(:reply) {should == ''} 71 | its(:plain) {should == plain_command} 72 | end 73 | 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /spec/translate_exceptions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ftpd 4 | describe TranslateExceptions do 5 | 6 | class FooError < StandardError ; end 7 | class BarError < StandardError ; end 8 | 9 | class Subject 10 | 11 | include TranslateExceptions 12 | 13 | def initialize 14 | translate_exception FooError 15 | end 16 | 17 | def raise_error(error, message) 18 | raise error, message 19 | end 20 | translate_exceptions :raise_error 21 | 22 | end 23 | 24 | let(:subject) {Subject.new} 25 | let(:message) {'An error happened'} 26 | 27 | it 'should translate a registered error' do 28 | expect { 29 | subject.raise_error(FooError, message) 30 | }.to raise_error PermanentFileSystemError, message 31 | end 32 | 33 | it 'should pass through an unregistered error' do 34 | expect { 35 | subject.raise_error(BarError, message) 36 | }.to raise_error BarError, message 37 | end 38 | 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /testlib/network.rb: -------------------------------------------------------------------------------- 1 | module TestLib 2 | module Network 3 | 4 | extend self 5 | 6 | def ipv6_supported? 7 | begin 8 | server = TCPServer.new("::1", 0) 9 | server.close 10 | true 11 | rescue Errno::EADDRNOTAVAIL 12 | false 13 | end 14 | end 15 | 16 | end 17 | end 18 | --------------------------------------------------------------------------------