├── .codeclimate.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .rubocop_todo.yml ├── .simplecov ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTORS.md ├── Gemfile ├── Guardfile ├── LICENSE.md ├── README.md ├── Rakefile ├── fake_ftp.gemspec ├── lib ├── fake_ftp.rb └── fake_ftp │ ├── file.rb │ ├── server.rb │ ├── server_commands.rb │ ├── server_commands │ ├── acct.rb │ ├── cdup.rb │ ├── cwd.rb │ ├── dele.rb │ ├── list.rb │ ├── mdtm.rb │ ├── mkd.rb │ ├── nlst.rb │ ├── pass.rb │ ├── pasv.rb │ ├── port.rb │ ├── pwd.rb │ ├── quit.rb │ ├── retr.rb │ ├── rnfr.rb │ ├── rnto.rb │ ├── site.rb │ ├── size.rb │ ├── stor.rb │ ├── type.rb │ ├── user.rb │ └── wat.rb │ └── version.rb └── spec ├── fixtures ├── invisible_bike.jpg └── text_file.txt ├── functional └── server_spec.rb ├── integration └── server_spec.rb ├── models └── fake_ftp │ ├── file_spec.rb │ └── server_spec.rb └── spec_helper.rb /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | duplication: 4 | enabled: true 5 | config: 6 | languages: 7 | - ruby 8 | fixme: 9 | enabled: true 10 | rubocop: 11 | enabled: false 12 | ratings: 13 | paths: 14 | - "**.rb" 15 | exclude_paths: 16 | - spec/ 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | pkg/* 3 | *.gem 4 | .bundle 5 | .DS_Store 6 | .rbenv-version 7 | .rbenv-gemsets 8 | /Gemfile.lock 9 | /coverage/ 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format documentation 3 | --order rand 4 | --require spec_helper 5 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | AllCops: 4 | TargetRubyVersion: '2.4' 5 | 6 | Style/Documentation: 7 | Enabled: false 8 | 9 | Metrics/AbcSize: 10 | Max: 40 11 | 12 | Metrics/CyclomaticComplexity: 13 | Max: 15 14 | 15 | Metrics/MethodLength: 16 | Max: 45 17 | 18 | Metrics/PerceivedComplexity: 19 | Max: 15 20 | 21 | Metrics/BlockLength: 22 | Exclude: 23 | - 'spec/**/*' 24 | Max: 40 25 | 26 | Metrics/ClassLength: 27 | Max: 200 28 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2017-08-01 12:55:33 -0400 using RuboCop version 0.49.1. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | if ENV['COVERAGE'] == '1' 2 | SimpleCov.start do 3 | add_filter '/spec/' 4 | minimum_coverage 90 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: false 3 | dist: trusty 4 | cache: 5 | bundler: true 6 | directories: 7 | - ~/.local/bin 8 | rvm: 9 | - 2.3.6 10 | - 2.4.3 11 | - 2.5.0 12 | env: 13 | matrix: 14 | - COVERAGE=0 FUNCTIONAL_SPECS=0 INTEGRATION_SPECS=0 15 | - COVERAGE=1 FUNCTIONAL_SPECS=1 INTEGRATION_SPECS=1 16 | global: 17 | - PATH="$HOME/.local/bin:$PATH" 18 | before_install: 19 | - gem update --system 20 | - if ! cc-test-reporter --version; then 21 | mkdir -p ~/.local/bin; 22 | curl -sSL 23 | -o ~/.local/bin/cc-test-reporter 24 | https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64; 25 | chmod +x ~/.local/bin/cc-test-reporter; 26 | fi 27 | - cc-test-reporter before-build 28 | after_script: 29 | - if [[ "${TRAVIS_PULL_REQUEST}" == "false" && 30 | "${COVERAGE}" == "1" && 31 | "${FUNCTIONAL_SPECS}" == "1" && 32 | "${INTEGRATION_SPECS}" == "1" && 33 | "${TRAVIS_RUBY_VERSION}" == "2.4.1" ]]; then 34 | cc-test-reporter after-build --exit-code "${TRAVIS_TEST_RESULT}"; 35 | fi 36 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ### Added 10 | 11 | ### Changed 12 | 13 | ### Deprecated 14 | 15 | ### Removed 16 | 17 | ### Fixed 18 | 19 | ### Security 20 | 21 | ## [0.3.0] - 2017-08-01 22 | ### Added 23 | - integration with Code Climate + simplecov 24 | - RuboCop with auto-fixes 25 | - this changelog 26 | - server: more debugging 27 | 28 | ### Changed 29 | - server: 30 | - command dispatch via class 31 | - store files as a set 32 | 33 | ### Removed 34 | - tests: running against ruby 2.2.7 35 | 36 | ## [0.2.0] - 2017-07-26 37 | ### Added 38 | - server: `size` command implementation 39 | 40 | ### Changed 41 | - tests: updated ruby versions tested via Travis CI 42 | 43 | ## [0.1.1] - 2014-04-17 44 | ### Added 45 | - documentation: more, plus a contributors doc 46 | 47 | ### Changed 48 | - server: 49 | - fail to initialize if control or passive ports are invalid 50 | - reset port from initial server address 51 | 52 | ## [0.1.0] - 2013-09-30 53 | ### Added 54 | - tests: integration with Travis CI 55 | - documentation: GitHub-flavored markdown changes 56 | - file: `last_modified_time` 57 | - server: 58 | - wildcard support in `list` command 59 | - `mdtm` command implementation 60 | - `rnfr` command implementation 61 | - `rnto` command implementation 62 | - `dele` command implementation 63 | - `mkd` command implementation 64 | 65 | ### Changed 66 | - server: real implementation of `cwd` command 67 | 68 | ## [0.0.9] - 2011-11-21 69 | ### Changed 70 | - server: initial respond code `220` 71 | 72 | ## [0.0.8] - 2011-11-21 73 | ### Changed 74 | - server: detect running state via `TCPSocket` instead of `lsof` 75 | 76 | ## [0.0.7] - 2011-10-07 77 | ### Added 78 | - server: `reset` command to clear stored files 79 | 80 | ## [0.0.6] - 2011-06-12 81 | ### Changed 82 | - server: pass args to commands via splat 83 | 84 | ## [0.0.5] - 2011-05-12 85 | ### Added 86 | - file: accessors for data and created fields 87 | - server: 88 | - `add_file` method for direct file addition 89 | - `retr` command implementation 90 | - `list` command implementation 91 | - `nlst` command implementation 92 | 93 | ## [0.0.4] - 2011-03-06 94 | ### Added 95 | - docs: show how to test active upload 96 | 97 | ### Changed 98 | - file: accept active/passive type at initialization 99 | 100 | ## [0.0.3] - 2011-03-06 101 | ### Added 102 | - server: 103 | - initial active mode implementation 104 | - `port` command 105 | 106 | ### Changed 107 | - server: 108 | - `stor` behavior depending on active/passive mode 109 | 110 | ## [0.0.2] - 2011-03-05 111 | ### Added 112 | - file: initial implementation for in-memory store 113 | - server: 114 | - `#files` method for fetching all stored file names 115 | - `#file` method for fetching rich file object by name 116 | 117 | ### Changed 118 | - server: use in-memory store instead of local scratch directory 119 | 120 | ## 0.0.1 - 2011-02-28 121 | 122 | ### Added 123 | - initial release with basic usage and docs 124 | 125 | [Unreleased]: https://github.com/livinginthepast/fake_ftp/compare/v0.2.0...HEAD 126 | [0.2.0]: https://github.com/livinginthepast/fake_ftp/compare/v0.1.1...v0.2.0 127 | [0.1.1]: https://github.com/livinginthepast/fake_ftp/compare/v0.1.0...v0.1.1 128 | [0.1.0]: https://github.com/livinginthepast/fake_ftp/compare/v0.0.9...v0.1.0 129 | [0.0.9]: https://github.com/livinginthepast/fake_ftp/compare/v0.0.8...v0.0.9 130 | [0.0.8]: https://github.com/livinginthepast/fake_ftp/compare/v0.0.7...v0.0.8 131 | [0.0.7]: https://github.com/livinginthepast/fake_ftp/compare/v0.0.6...v0.0.7 132 | [0.0.6]: https://github.com/livinginthepast/fake_ftp/compare/v0.0.5...v0.0.6 133 | [0.0.5]: https://github.com/livinginthepast/fake_ftp/compare/v0.0.4...v0.0.5 134 | [0.0.4]: https://github.com/livinginthepast/fake_ftp/compare/v0.0.3...v0.0.4 135 | [0.0.3]: https://github.com/livinginthepast/fake_ftp/compare/v0.0.2...v0.0.3 136 | [0.0.2]: https://github.com/livinginthepast/fake_ftp/compare/v0.0.1...v0.0.2 137 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | Contributors 2 | ============ 3 | 4 | * Eric Saxby - @sax 5 | 6 | * Ben Ashford 7 | * Colin Shield 8 | * Eirik Lied 9 | * Eric Oestrich 10 | * Jacob Maine 11 | * John Maxwell 12 | * Kevin Thompson 13 | * liehann 14 | * mdalton 15 | * Nick Rowe 16 | * Pranas Kiziela 17 | * Puneet Goyal 18 | * Steve Thompson 19 | * Thomas Sonntag 20 | * runongirlrunon 21 | * Dan Buch 22 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'http://rubygems.org' 4 | 5 | gemspec 6 | 7 | group :test do 8 | gem 'guard-rspec', '~> 4.7.3' 9 | gem 'pry-nav', '~> 0.2.4' 10 | gem 'rake', '~> 12.0.0' 11 | gem 'rspec', '~> 3.6.0' 12 | gem 'rubocop', '~> 0.49.1' 13 | gem 'simplecov', '~> 0.14.1', require: false 14 | end 15 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A sample Guardfile 4 | # More info at https://github.com/guard/guard#readme 5 | 6 | guard 'rspec', version: 2 do 7 | watch(%r{^spec/.+_spec\.rb$}) 8 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } 9 | watch('spec/spec_helper.rb') { 'spec' } 10 | watch(%r{^spec/support/(.+)\.rb$}) { 'spec' } 11 | end 12 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2017 Eric Saxby 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FakeFtp 2 | 3 | **Archived:** This gem was written for a specific need that I had at the time. I no longer actively 4 | work in Ruby professionally, and avoid FTP as a protocol when possible. Please consider using 5 | alternatives to this gem if possible. 6 | 7 | [![Build status](https://api.travis-ci.org/livinginthepast/fake_ftp.svg?branch=master)](http://travis-ci.org/livinginthepast/fake_ftp) 8 | [![Code Climate](https://codeclimate.com/github/livinginthepast/fake_ftp/badges/gpa.svg)](https://codeclimate.com/github/livinginthepast/fake_ftp) 9 | [![Test Coverage](https://codeclimate.com/github/livinginthepast/fake_ftp/badges/coverage.svg)](https://codeclimate.com/github/livinginthepast/fake_ftp/coverage) 10 | [![Gem Version](https://badge.fury.io/rb/fake_ftp.svg)](https://badge.fury.io/rb/fake_ftp) 11 | 12 | This is a gem that allows you to test FTP implementations in ruby. It is a 13 | minimal single-client FTP server that can be bound to any arbitrary port on 14 | localhost. 15 | 16 | ## Why? 17 | 18 | We want to ensure that our code works, in a way that is agnostic to the 19 | implementation used (unlike with stubs or mocks). 20 | 21 | ## How 22 | 23 | FakeFtp is a simple FTP server that fakes out enough of the protocol to get us 24 | by, allowing us to test that files get to their intended destination rather than 25 | testing how our code does so. 26 | 27 | ## Usage 28 | 29 | To test passive upload: 30 | ``` ruby 31 | require 'fake_ftp' 32 | require 'net/ftp' 33 | 34 | server = FakeFtp::Server.new(21212, 21213) 35 | ## 21212 is the control port, which is used by FTP for the primary connection 36 | ## 21213 is the data port, used in FTP passive mode to send file contents 37 | server.start 38 | 39 | ftp = Net::FTP.new 40 | ftp.connect('127.0.0.1', 21212) 41 | ftp.login('user', 'password') 42 | ftp.passive = true 43 | ftp.put('some_file.txt') 44 | ftp.close 45 | 46 | expect(server.files).to include('some_file.txt') 47 | expect(server.file('some_file.txt').bytes).to eq 25 48 | expect(server.file('some_file.txt')).to be_passive 49 | expect(server.file('some_file.txt')).to_not be_active 50 | 51 | server.stop 52 | ``` 53 | 54 | To test active upload: 55 | ``` ruby 56 | server = FakeFtp::Server.new(21212) 57 | ## 21212 is the control port, which is used by FTP for the primary connection 58 | ## 21213 is the data port, used in FTP passive mode to send file contents 59 | server.start 60 | 61 | ftp = Net::FTP.new 62 | ftp.connect('127.0.0.1', 21212) 63 | ftp.login('user', 'password') 64 | ftp.passive = false 65 | ftp.put('some_file.txt') 66 | ftp.close 67 | 68 | expect(server.files).to include('some_file.txt') 69 | expect(server.file('some_file.txt').bytes).to eq 25 70 | expect(server.file('some_file.txt')).to be_active 71 | expect(server.file('some_file.txt')).to_not be_passive 72 | 73 | server.stop 74 | ``` 75 | 76 | Note that many FTP clients default to active, unless specified otherwise. 77 | 78 | ## Caveats 79 | 80 | This is *not* a real FTP server and should not be treated as one. The goal of 81 | this gem is not to create a thread-safe multi-client implementation. It is best 82 | used to unit test code that generates files and transfers them to an FTP server. 83 | 84 | As such, there are some things that won't be accepted upstream from pull 85 | requests: 86 | * simultaneous multi-client code 87 | * persistence support 88 | * binding to arbitrary IPs 89 | * global state beyond that required to pass the minimum required to 90 | generate passing tests 91 | 92 | ## Recommendations for testing patterns 93 | 94 | *Separate configuration from code.* Do not hard code the IP address, FQDN or 95 | port of an FTP server in your code. It introduces fragility into your tests. 96 | Also, the default FTP port of 21 is a privileged port, and should be avoided. 97 | 98 | *Separate the code that generates files from the code that uploads files.* You 99 | tests will run much more quickly if you only try to upload small files. If you 100 | have tests showing that you generate correct files from your data, then you can 101 | trust that. Why do you need to upload a 20M file in your tests if you can stub 102 | out your file generation method and test file upload against 10 bytes? Fast fast 103 | fast. 104 | 105 | ## References 106 | 107 | * http://rubyforge.org/projects/ftpd/ - a simple ftp daemon written by Chris Wanstrath 108 | * http://ruby-doc.org/stdlib/libdoc/gserver/rdoc/index.html - a generic server in the Ruby standard library, by John W Small 109 | 110 | ## Alternatives 111 | 112 | * https://github.com/thejamespinto/ftpmock - Test your FTP calls offline. 113 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | require 'bundler' 5 | require 'rspec/core/rake_task' 6 | require 'rubocop/rake_task' 7 | rescue LoadError => e 8 | warn e 9 | end 10 | 11 | Bundler::GemHelper.install_tasks if defined?(Bundler) 12 | RSpec::Core::RakeTask.new if defined?(RSpec) 13 | RuboCop::RakeTask.new if defined?(RuboCop) 14 | 15 | task default: %i[rubocop spec] 16 | -------------------------------------------------------------------------------- /fake_ftp.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # frozen_string_literal: true 3 | 4 | $LOAD_PATH.unshift(File.expand_path('../lib', __FILE__)) 5 | require 'fake_ftp/version' 6 | 7 | Gem::Specification.new do |s| 8 | s.name = 'fake_ftp' 9 | s.version = FakeFtp::VERSION 10 | s.platform = Gem::Platform::RUBY 11 | s.required_ruby_version = '>= 2.3.0' # For Safe Navigation Operator 12 | s.authors = ['Colin Shield', 'Eric Saxby'] 13 | s.email = ['sax+github@livinginthepast.org'] 14 | s.homepage = 'http://rubygems.org/gems/fake_ftp' 15 | s.summary = 'Creates a fake FTP server for use in testing' 16 | s.description = 'Testing FTP? Use this!' 17 | s.license = 'MIT' 18 | 19 | s.files = `git ls-files -z`.split("\0") 20 | s.test_files = `git ls-files -z -- spec/*`.split("\0") 21 | s.require_paths = %w[lib] 22 | end 23 | -------------------------------------------------------------------------------- /lib/fake_ftp.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | autoload :Server, 'fake_ftp/server' 5 | autoload :File, 'fake_ftp/file' 6 | end 7 | -------------------------------------------------------------------------------- /lib/fake_ftp/file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | class File 5 | attr_accessor :bytes, :data, :name, :last_modified_time 6 | attr_writer :type 7 | attr_reader :created 8 | 9 | def initialize(name = nil, data = nil, type = nil, 10 | last_modified_time = Time.now) 11 | @created = Time.now 12 | @name = name 13 | @data = data 14 | # FIXME: this is far too ambiguous. args should not mean different 15 | # things in different contexts. 16 | data_is_bytes = (data.nil? || data.is_a?(Integer)) 17 | @bytes = data_is_bytes ? data : data.to_s.length 18 | @data = data_is_bytes ? nil : data 19 | @type = type 20 | @last_modified_time = last_modified_time.utc 21 | end 22 | 23 | def basename 24 | ::File.basename(@name) 25 | end 26 | 27 | def data=(data) 28 | @data = data 29 | @bytes = @data.nil? ? nil : data.length 30 | end 31 | 32 | def passive? 33 | @type == :passive 34 | end 35 | 36 | def active? 37 | @type == :active 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/fake_ftp/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'socket' 4 | require 'thread' 5 | require 'timeout' 6 | 7 | module FakeFtp 8 | class Server 9 | attr_accessor :client, :command_state, :data_server, :passive_port 10 | attr_accessor :port, :store, :workdir 11 | 12 | alias path workdir 13 | 14 | def initialize(control_port = 21, data_port = nil, options = {}) 15 | @port = control_port 16 | @passive_port = data_port 17 | @store = {} 18 | @workdir = '/pub' 19 | @options = options 20 | @command_state = {} 21 | 22 | @connection = nil 23 | @data_server = nil 24 | @server = nil 25 | @client = nil 26 | 27 | raise Errno::EADDRINUSE, port.to_s if !control_port.zero? && running? 28 | 29 | if passive_port && !passive_port.zero? && running?(passive_port) 30 | raise Errno::EADDRINUSE, passive_port.to_s 31 | end 32 | 33 | self.mode = options.fetch(:mode, :active) 34 | self.absolute = options.fetch(:absolute, false) 35 | end 36 | 37 | def files 38 | @store.values.map do |f| 39 | if absolute? 40 | abspath(f.name) 41 | else 42 | f.name 43 | end 44 | end 45 | end 46 | 47 | def file(name) 48 | @store.values.detect do |f| 49 | if absolute? 50 | abspath(f.name) == name 51 | else 52 | f.name == name 53 | end 54 | end 55 | end 56 | 57 | def reset 58 | @store.clear 59 | end 60 | 61 | def add_file(filename, data, last_modified_time = Time.now) 62 | @store[abspath(filename)] = FakeFtp::File.new( 63 | filename.to_s, data, options[:mode], last_modified_time 64 | ) 65 | end 66 | 67 | def start 68 | @started = true 69 | @server = ::TCPServer.new('127.0.0.1', port) 70 | @port = @server.addr[1] 71 | @thread = Thread.new do 72 | while @started 73 | debug('enter client loop') 74 | @client = begin 75 | @server.accept 76 | rescue => e 77 | debug("error on accept: #{e}") 78 | nil 79 | end 80 | next unless @client 81 | respond_with('220 Can has FTP?') 82 | @connection = Thread.new(@client) do |socket| 83 | debug('enter request thread') 84 | while @started && !socket.nil? && !socket.closed? 85 | input = begin 86 | socket.gets 87 | rescue 88 | debug("error on socket.gets: #{e}") 89 | nil 90 | end 91 | if input 92 | debug("server client raw: <- #{input.inspect}") 93 | respond_with(handle_request(input)) 94 | end 95 | end 96 | unless @client.nil? 97 | @client.close unless @client.closed? 98 | @client = nil 99 | end 100 | debug('leave request thread') 101 | end 102 | debug('leave client loop') 103 | end 104 | unless @server.nil? 105 | @server.close unless @server.closed? 106 | @server = nil 107 | end 108 | end 109 | 110 | return unless passive_port 111 | @data_server = ::TCPServer.new('127.0.0.1', passive_port) 112 | @passive_port = @data_server.addr[1] 113 | end 114 | 115 | def stop 116 | @started = false 117 | @client&.close 118 | @server&.close 119 | @server = nil 120 | @data_server&.close 121 | @data_server = nil 122 | end 123 | 124 | def running?(tcp_port = nil) 125 | return port_is_open?(port) if tcp_port.nil? 126 | port_is_open?(tcp_port) 127 | end 128 | 129 | alias is_running? running? 130 | 131 | def mode=(value) 132 | unless %i[active passive].include?(value) 133 | raise ArgumentError, "invalid mode #{value.inspect}" 134 | end 135 | options[:mode] = value 136 | end 137 | 138 | def mode 139 | options[:mode] 140 | end 141 | 142 | def absolute? 143 | options[:absolute] 144 | end 145 | 146 | def absolute=(value) 147 | unless [true, false].include?(value) 148 | raise ArgumentError, "invalid absolute #{value}" 149 | end 150 | options[:absolute] = value 151 | end 152 | 153 | attr_reader :options 154 | private :options 155 | 156 | def abspath(filename) 157 | return filename if filename.start_with?('/') 158 | [@workdir.to_s, filename].join('/').gsub('//', '/') 159 | end 160 | 161 | def respond_with(stuff) 162 | return if stuff.nil? || @client.nil? || @client.closed? 163 | debug("server client raw: -> #{stuff.inspect}") 164 | @client.print(stuff + "\r\n") 165 | end 166 | 167 | private def handle_request(request) 168 | return if request.nil? 169 | debug("raw request: #{request.inspect}") 170 | command = request[0, 4].downcase.strip 171 | contents = request.split 172 | message = contents[1..contents.length] 173 | 174 | inst = load_command_instance(command) 175 | return "500 Unknown command #{command.inspect}" if inst.nil? 176 | debug( 177 | "running command #{command.inspect} " \ 178 | "#{inst.class.name}#run(*#{message.inspect})" 179 | ) 180 | inst.run(*([self] + message)) 181 | end 182 | 183 | private def load_command_instance(command) 184 | require "fake_ftp/server_commands/#{command}" 185 | FakeFtp::ServerCommands.constants.each do |const_name| 186 | next unless const_name.to_s.downcase == command 187 | return FakeFtp::ServerCommands.const_get(const_name).new 188 | end 189 | nil 190 | rescue LoadError => e 191 | debug("failed to require #{command.inspect} class: #{e}") 192 | nil 193 | end 194 | 195 | def active? 196 | options[:mode] == :active 197 | end 198 | 199 | private def port_is_open?(port) 200 | begin 201 | Timeout.timeout(1) do 202 | begin 203 | TCPSocket.new('127.0.0.1', port).close 204 | return true 205 | rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH 206 | return false 207 | end 208 | end 209 | rescue Timeout::Error => e 210 | debug("timeout while checking port #{port}: #{e}") 211 | end 212 | 213 | false 214 | end 215 | 216 | def build_wildcards(args) 217 | wildcards = [] 218 | args.each do |arg| 219 | next unless arg.include? '*' 220 | wildcards << arg.gsub('*', '.*') 221 | end 222 | wildcards 223 | end 224 | 225 | def matching_files(wildcards) 226 | if !wildcards.empty? 227 | @store.values.select do |f| 228 | wildcards.any? { |wildcard| f.name =~ /#{wildcard}/ } 229 | end 230 | else 231 | @store.values 232 | end 233 | end 234 | 235 | def debug(msg) 236 | return unless options[:debug] 237 | $stderr.puts("DEBUG:fake_ftp:#{msg}") 238 | end 239 | end 240 | end 241 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/acct.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Acct 6 | def run(*) 7 | '230 WHATEVER!' 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/cdup.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Cdup 6 | def run(*) 7 | '250 OK!' 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/cwd.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Cwd 6 | def run(ctx, wd, *) 7 | wd = "/#{wd}" unless wd.start_with?('/') 8 | ctx.workdir = wd 9 | '250 OK!' 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/dele.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Dele 6 | def run(ctx, filename = '', *) 7 | files_to_delete = ctx.store.values.select do |f| 8 | if ctx.absolute? 9 | ctx.abspath(::File.basename(filename)) == ctx.abspath(f.name) 10 | else 11 | ::File.basename(filename) == f.name 12 | end 13 | end 14 | 15 | return '550 Delete operation failed.' if files_to_delete.empty? 16 | 17 | ctx.store.reject! do |_, f| 18 | files_to_delete.include?(f) 19 | end 20 | 21 | '250 Delete operation successful.' 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class List 6 | def run(ctx, *args) 7 | if ctx.active? && ctx.command_state[:active_connection].nil? 8 | ctx.respond_with('425 Ain\'t no data port!') 9 | return 10 | end 11 | 12 | ctx.respond_with('150 Listing status ok, about to open data connection') 13 | data_client = if ctx.active? 14 | ctx.command_state[:active_connection] 15 | else 16 | ctx.data_server.accept 17 | end 18 | 19 | wildcards = ctx.build_wildcards(args) 20 | statlines = ctx.matching_files(wildcards).map do |f| 21 | %W[ 22 | -rw-r--r-- 23 | 1 24 | owner 25 | group 26 | #{f.bytes} 27 | #{f.created.strftime('%b %d %H:%M')} 28 | #{f.name} 29 | ].join("\t") 30 | end 31 | data_client.write(statlines.join("\n")) 32 | data_client.close 33 | ctx.command_state[:active_connection] = nil 34 | 35 | '226 List information transferred' 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/mdtm.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Mdtm 6 | def run(ctx, filename = '', *) 7 | ctx.respond_with('501 No filename given') && return if filename.empty? 8 | server_file = ctx.file(filename) 9 | ctx.respond_with('550 File not found') && return if server_file.nil? 10 | 11 | ctx.respond_with( 12 | "213 #{server_file.last_modified_time.strftime('%Y%m%d%H%M%S')}" 13 | ) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/mkd.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Mkd 6 | def run(*) 7 | '257 OK!' 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/nlst.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Nlst 6 | def run(ctx, *args) 7 | if ctx.active? && ctx.command_state[:active_connection].nil? 8 | ctx.respond_with('425 Ain\'t no data port!') 9 | return 10 | end 11 | 12 | ctx.respond_with('150 Listing status ok, about to open data connection') 13 | data_client = if ctx.active? 14 | ctx.command_state[:active_connection] 15 | else 16 | ctx.data_server.accept 17 | end 18 | 19 | wildcards = ctx.build_wildcards(args) 20 | matching = ctx.matching_files(wildcards).map do |f| 21 | "#{f.name}\n" 22 | end 23 | 24 | data_client.write(matching.join) 25 | data_client.close 26 | ctx.command_state[:active_connection] = nil 27 | 28 | '226 List information transferred' 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/pass.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Pass 6 | def run(*) 7 | '230 logged in' 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/pasv.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Pasv 6 | def run(ctx, *) 7 | return '502 Aww hell no, use Active' if ctx.passive_port.nil? 8 | ctx.mode = :passive 9 | p1 = (ctx.passive_port / 256).to_i 10 | p2 = ctx.passive_port % 256 11 | "227 Entering Passive Mode (127,0,0,1,#{p1},#{p2})" 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/port.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Port 6 | def run(ctx, remote = '', *) 7 | remote = remote.split(',') 8 | remote_port = remote[4].to_i * 256 + remote[5].to_i 9 | unless ctx.command_state[:active_connection].nil? 10 | ctx.command_state[:active_connection].close 11 | ctx.command_state[:active_connection] = nil 12 | end 13 | ctx.mode = :active 14 | ctx.debug('_port active connection ->') 15 | ctx.command_state[:active_connection] = ::TCPSocket.new( 16 | '127.0.0.1', remote_port 17 | ) 18 | ctx.debug('_port active connection <-') 19 | '200 Okay' 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/pwd.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Pwd 6 | def run(ctx, *) 7 | "257 \"#{ctx.workdir}\" is current directory" 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/quit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Quit 6 | def run(ctx, *) 7 | ctx.respond_with '221 OMG bye!' 8 | ctx.client&.close 9 | ctx.client = nil 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/retr.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Retr 6 | def run(ctx, filename = '', *) 7 | ctx.respond_with('501 No filename given') if filename.empty? 8 | 9 | f = ctx.file(filename.to_s) 10 | return ctx.respond_with('550 File not found') if f.nil? 11 | 12 | if ctx.active? && ctx.command_state[:active_connection].nil? 13 | ctx.respond_with('425 Ain\'t no data port!') 14 | return 15 | end 16 | 17 | ctx.respond_with('150 File status ok, about to open data connection') 18 | data_client = if ctx.active? 19 | ctx.command_state[:active_connection] 20 | else 21 | ctx.data_server.accept 22 | end 23 | 24 | data_client.write(f.data) 25 | 26 | data_client.close 27 | ctx.command_state[:active_connection] = nil 28 | '226 File transferred' 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/rnfr.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Rnfr 6 | def run(ctx, rename_from = '', *) 7 | return '501 Send path name.' if rename_from.nil? || rename_from.empty? 8 | 9 | ctx.command_state[:rename_from] = if ctx.absolute? 10 | ctx.abspath(rename_from) 11 | else 12 | rename_from 13 | end 14 | '350 Send RNTO to complete rename.' 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/rnto.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Rnto 6 | def run(ctx, rename_to = '', *) 7 | return '501 Send path name.' if rename_to.nil? || rename_to.empty? 8 | return '503 Send RNFR first.' if ctx.command_state[:rename_from].nil? 9 | 10 | f = ctx.file(ctx.command_state[:rename_from]) 11 | if f.nil? 12 | ctx.command_state[:rename_from] = nil 13 | return '550 File not found.' 14 | end 15 | 16 | f.name = rename_to 17 | ctx.command_state[:rename_from] = nil 18 | '250 Path renamed.' 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/site.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Site 6 | def run(_, command, *) 7 | "200 #{command}" 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/size.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Size 6 | def run(ctx, filename, *) 7 | ctx.respond_with("213 #{ctx.file(filename).bytes}") 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/stor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Stor 6 | def run(ctx, filename = '', *) 7 | if ctx.active? && ctx.command_state[:active_connection].nil? 8 | ctx.respond_with('425 Ain\'t no data port!') 9 | return 10 | end 11 | 12 | ctx.respond_with('125 Do it!') 13 | data_client = if ctx.active? 14 | ctx.command_state[:active_connection] 15 | else 16 | ctx.data_server.accept 17 | end 18 | 19 | data = data_client.read(nil) 20 | ctx.store[ctx.abspath(filename)] = FakeFtp::File.new( 21 | filename.to_s, data, ctx.mode 22 | ) 23 | 24 | data_client.close 25 | ctx.command_state[:active_connection] = nil 26 | '226 Did it!' 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Type 6 | def run(_, type = 'A', *) 7 | case type.to_s 8 | when 'A' 9 | '200 Type set to A.' 10 | when 'I' 11 | '200 Type set to I.' 12 | else 13 | '504 We don\'t allow those' 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class User 6 | def run(_, name, *) 7 | return '230 logged in' if name.to_s == 'anonymous' 8 | '331 send your password' 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/fake_ftp/server_commands/wat.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | module ServerCommands 5 | class Wat 6 | def run(ctx, *) 7 | if ctx.active? && ctx.command_state[:active_connection].nil? 8 | ctx.respond_with('425 Ain\'t no data port!') 9 | return 10 | end 11 | 12 | data_client = if ctx.active? 13 | ctx.command_state[:active_connection] 14 | else 15 | ctx.data_server.accept 16 | end 17 | data_client.write(invisible_bike) 18 | data_client.close 19 | ctx.command_state[:active_connection] = nil 20 | '418 Pizza Party' 21 | end 22 | 23 | private def invisible_bike 24 | ::File.read( 25 | ::File.expand_path( 26 | '../../../../spec/fixtures/invisible_bike.jpg', 27 | __FILE__ 28 | ) 29 | ) 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/fake_ftp/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FakeFtp 4 | VERSION = '0.3.0' 5 | end 6 | -------------------------------------------------------------------------------- /spec/fixtures/invisible_bike.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livinginthepast/fake_ftp/bb65aaf9ebc8a24d894e48db45f597e0e45d82e9/spec/fixtures/invisible_bike.jpg -------------------------------------------------------------------------------- /spec/fixtures/text_file.txt: -------------------------------------------------------------------------------- 1 | Hello im a text file -------------------------------------------------------------------------------- /spec/functional/server_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe FakeFtp::Server, 'commands', functional: true do 4 | let(:absolute?) { true } 5 | let(:data_port) { rand(16_000..19_000) } 6 | let(:data_addr_bits) { SpecHelper.local_addr_bits(data_port) } 7 | let(:client_port) { rand(19_000..22_000) } 8 | let(:client_addr_bits) { SpecHelper.local_addr_bits(client_port) } 9 | let(:data_server_port) { rand(22_000..24_000) } 10 | 11 | let(:client) do 12 | TCPSocket.open('127.0.0.1', client_port).tap { |s| s.sync = true } 13 | end 14 | 15 | let(:server) do 16 | FakeFtp::Server.new( 17 | client_port, data_port, 18 | debug: ENV['DEBUG'] == '1', 19 | absolute: absolute? 20 | ) 21 | end 22 | 23 | let(:data_server) do 24 | SpecHelper::FakeDataServer.new(data_server_port) 25 | end 26 | 27 | before { server.start } 28 | 29 | after do 30 | client.close 31 | server.stop 32 | end 33 | 34 | context 'general' do 35 | it 'should accept connections' do 36 | expect(SpecHelper.gets_with_timeout(client)) 37 | .to eql("220 Can has FTP?\r\n") 38 | end 39 | 40 | it 'should get unknown command response when nothing is sent' do 41 | SpecHelper.gets_with_timeout(client) 42 | client.write("\r\n") 43 | expect(SpecHelper.gets_with_timeout(client)) 44 | .to match(/^500 Unknown command "[^"]*"\r\n/) 45 | end 46 | 47 | it 'accepts QUIT' do 48 | SpecHelper.gets_with_timeout(client) 49 | client.write("QUIT\r\n") 50 | expect(SpecHelper.gets_with_timeout(client)).to eql("221 OMG bye!\r\n") 51 | end 52 | 53 | it 'should accept multiple commands in one session' do 54 | SpecHelper.gets_with_timeout(client) 55 | client.write("USER thing\r\n") 56 | SpecHelper.gets_with_timeout(client) 57 | client.write("PASS thing\r\n") 58 | SpecHelper.gets_with_timeout(client) 59 | client.write("ACCT thing\r\n") 60 | SpecHelper.gets_with_timeout(client) 61 | client.write("USER thing\r\n") 62 | end 63 | 64 | it 'should accept SITE command' do 65 | SpecHelper.gets_with_timeout(client) 66 | client.write("SITE umask\r\n") 67 | expect(SpecHelper.gets_with_timeout(client)).to eql "200 umask\r\n" 68 | end 69 | end 70 | 71 | context 'passive' do 72 | it 'accepts PASV' do 73 | expect(server.mode).to eql(:active) 74 | SpecHelper.gets_with_timeout(client) 75 | client.write("PASV\r\n") 76 | expect(SpecHelper.gets_with_timeout(client)) 77 | .to eql("227 Entering Passive Mode (#{data_addr_bits})\r\n") 78 | expect(server.mode).to eql(:passive) 79 | end 80 | 81 | it 'responds with correct PASV port' do 82 | server.stop 83 | server.passive_port = 21_111 84 | server.start 85 | SpecHelper.gets_with_timeout(client) 86 | client.write("PASV\r\n") 87 | addr_bits = SpecHelper.local_addr_bits(21_111) 88 | expect(SpecHelper.gets_with_timeout(client)) 89 | .to eql("227 Entering Passive Mode (#{addr_bits})\r\n") 90 | end 91 | 92 | it 'does not accept PASV if no port set' do 93 | server.stop 94 | server.passive_port = nil 95 | server.start 96 | SpecHelper.gets_with_timeout(client) 97 | client.write("PASV\r\n") 98 | expect(SpecHelper.gets_with_timeout(client)) 99 | .to eql("502 Aww hell no, use Active\r\n") 100 | end 101 | end 102 | 103 | context 'active' do 104 | before :each do 105 | SpecHelper.gets_with_timeout(client) 106 | data_server.start 107 | end 108 | 109 | after :each do 110 | data_server.stop 111 | end 112 | 113 | it 'accepts PORT and connects to port' do 114 | client.write("PORT #{data_server.addr_bits}\r\n") 115 | expect(SpecHelper.gets_with_timeout(client)).to eql("200 Okay\r\n") 116 | end 117 | 118 | it 'should switch to :active on port command' do 119 | expect(server.mode).to eql(:active) 120 | client.write("PASV\r\n") 121 | SpecHelper.gets_with_timeout(client) 122 | expect(server.mode).to eql(:passive) 123 | 124 | client.write("PORT #{data_server.addr_bits}\r\n") 125 | expect(SpecHelper.gets_with_timeout(client)).to eql("200 Okay\r\n") 126 | 127 | expect(server.mode).to eql(:active) 128 | end 129 | end 130 | 131 | context 'authentication commands' do 132 | before :each do 133 | SpecHelper.gets_with_timeout(client) 134 | end 135 | 136 | it 'accepts USER' do 137 | client.write("USER some_dude\r\n") 138 | expect(SpecHelper.gets_with_timeout(client)) 139 | .to eql("331 send your password\r\n") 140 | end 141 | 142 | it 'accepts anonymous USER' do 143 | client.write("USER anonymous\r\n") 144 | expect(SpecHelper.gets_with_timeout(client)).to eql("230 logged in\r\n") 145 | end 146 | 147 | it 'accepts PASS' do 148 | client.write("PASS password\r\n") 149 | expect(SpecHelper.gets_with_timeout(client)).to eql("230 logged in\r\n") 150 | end 151 | 152 | it 'accepts ACCT' do 153 | client.write("ACCT\r\n") 154 | expect(SpecHelper.gets_with_timeout(client)).to eql("230 WHATEVER!\r\n") 155 | end 156 | end 157 | 158 | [true, false].each do |absolute| 159 | let(:absolute?) { absolute } 160 | 161 | context "directory commands with absolute=#{absolute}" do 162 | before :each do 163 | SpecHelper.gets_with_timeout(client) 164 | end 165 | 166 | it 'returns directory on PWD' do 167 | client.write("PWD\r\n") 168 | expect(SpecHelper.gets_with_timeout(client)) 169 | .to eql("257 \"/pub\" is current directory\r\n") 170 | end 171 | 172 | it 'says OK to any CWD, CDUP, without doing anything' do 173 | client.write("CWD somewhere/else\r\n") 174 | expect(SpecHelper.gets_with_timeout(client)).to eql("250 OK!\r\n") 175 | client.write("CDUP\r\n") 176 | expect(SpecHelper.gets_with_timeout(client)).to eql("250 OK!\r\n") 177 | end 178 | end 179 | end 180 | 181 | [true, false].each do |absolute| 182 | context "file commands with absolute=#{absolute}" do 183 | let(:absolute?) { absolute } 184 | let(:file_prefix) { absolute ? '/pub/' : '' } 185 | 186 | before :each do 187 | SpecHelper.gets_with_timeout(client) 188 | end 189 | 190 | it 'accepts TYPE ascii' do 191 | client.write("TYPE A\r\n") 192 | expect(SpecHelper.gets_with_timeout(client)) 193 | .to eql("200 Type set to A.\r\n") 194 | end 195 | 196 | it 'accepts TYPE image' do 197 | client.write("TYPE I\r\n") 198 | expect(SpecHelper.gets_with_timeout(client)) 199 | .to eql("200 Type set to I.\r\n") 200 | end 201 | 202 | it 'does not accept TYPEs other than ascii or image' do 203 | client.write("TYPE E\r\n") 204 | expect(SpecHelper.gets_with_timeout(client)) 205 | .to eql("504 We don't allow those\r\n") 206 | client.write("TYPE N\r\n") 207 | expect(SpecHelper.gets_with_timeout(client)) 208 | .to eql("504 We don't allow those\r\n") 209 | client.write("TYPE T\r\n") 210 | expect(SpecHelper.gets_with_timeout(client)) 211 | .to eql("504 We don't allow those\r\n") 212 | client.write("TYPE C\r\n") 213 | expect(SpecHelper.gets_with_timeout(client)) 214 | .to eql("504 We don't allow those\r\n") 215 | client.write("TYPE L\r\n") 216 | expect(SpecHelper.gets_with_timeout(client)) 217 | .to eql("504 We don't allow those\r\n") 218 | end 219 | 220 | context 'passive' do 221 | let(:data_client) do 222 | TCPSocket.open('127.0.0.1', data_port).tap { |c| c.sync = true } 223 | end 224 | 225 | before :each do 226 | client.write("PASV\r\n") 227 | expect(SpecHelper.gets_with_timeout(client)) 228 | .to eql("227 Entering Passive Mode (#{data_addr_bits})\r\n") 229 | end 230 | 231 | it 'accepts STOR with filename' do 232 | client.write("STOR some_file\r\n") 233 | expect(SpecHelper.gets_with_timeout(client)).to eql("125 Do it!\r\n") 234 | data_client.write('1234567890') 235 | data_client.close 236 | expect(SpecHelper.gets_with_timeout(client)).to eql("226 Did it!\r\n") 237 | expect(server.files).to include(file_prefix + 'some_file') 238 | expect(server.file(file_prefix + 'some_file').bytes).to eql(10) 239 | expect(server.file(file_prefix + 'some_file').data) 240 | .to eql('1234567890') 241 | end 242 | 243 | it 'accepts STOR with filename and trailing newline' do 244 | client.write("STOR some_file\r\n") 245 | SpecHelper.gets_with_timeout(client) 246 | data_client.write("1234567890\n") 247 | data_client.close 248 | expect(SpecHelper.gets_with_timeout(client)).to eql("226 Did it!\r\n") 249 | expect(server.files).to include(file_prefix + 'some_file') 250 | expect(server.file(file_prefix + 'some_file').bytes).to eql(11) 251 | expect(server.file(file_prefix + 'some_file').data) 252 | .to eql("1234567890\n") 253 | end 254 | 255 | it 'accepts STOR with filename and long file' do 256 | client.write("STOR some_file\r\n") 257 | expect(SpecHelper.gets_with_timeout(client)).to eql("125 Do it!\r\n") 258 | data_client.write('1234567890' * 10_000) 259 | data_client.close 260 | expect(SpecHelper.gets_with_timeout(client)).to eql("226 Did it!\r\n") 261 | expect(server.files).to include(file_prefix + 'some_file') 262 | end 263 | 264 | it 'accepts STOR with streams' do 265 | client.puts 'STOR some_file' 266 | expect(SpecHelper.gets_with_timeout(client)).to eql("125 Do it!\r\n") 267 | data_client.write '1234567890' 268 | data_client.flush 269 | data_client.write '1234567890' 270 | data_client.flush 271 | data_client.close 272 | expect(SpecHelper.gets_with_timeout(client)).to eql("226 Did it!\r\n") 273 | expect(server.file(file_prefix + 'some_file').data) 274 | .to eql('12345678901234567890') 275 | end 276 | 277 | it 'does not accept RETR without a filename' do 278 | client.write("RETR\r\n") 279 | expect(SpecHelper.gets_with_timeout(client)) 280 | .to eql("501 No filename given\r\n") 281 | end 282 | 283 | it 'does not serve files that do not exist' do 284 | client.write("RETR some_file\r\n") 285 | expect(SpecHelper.gets_with_timeout(client)) 286 | .to eql("550 File not found\r\n") 287 | end 288 | 289 | it 'accepts RETR with a filename' do 290 | server.add_file(file_prefix + 'some_file', '1234567890') 291 | client.write("RETR #{file_prefix}some_file\r\n") 292 | expect(SpecHelper.gets_with_timeout(client)) 293 | .to eql("150 File status ok, about to open data connection\r\n") 294 | data = SpecHelper.gets_with_timeout(data_client, endwith: "\0") 295 | data_client.close 296 | expect(data).to eql('1234567890') 297 | expect(SpecHelper.gets_with_timeout(client)) 298 | .to eql("226 File transferred\r\n") 299 | end 300 | 301 | it 'accepts DELE with a filename' do 302 | server.add_file('some_file', '1234567890') 303 | client.write("DELE some_file\r\n") 304 | expect(SpecHelper.gets_with_timeout(client)) 305 | .to eql("250 Delete operation successful.\r\n") 306 | expect(server.files).to_not include('some_file') 307 | end 308 | 309 | it 'gives error message when trying to delete a file ' \ 310 | 'that does not exist' do 311 | client.write("DELE non_existing_file\r\n") 312 | expect(SpecHelper.gets_with_timeout(client)) 313 | .to eql("550 Delete operation failed.\r\n") 314 | end 315 | 316 | it 'accepts a LIST command' do 317 | server.add_file(file_prefix + 'some_file', '1234567890') 318 | server.add_file(file_prefix + 'another_file', '1234567890') 319 | client.puts("LIST\r\n") 320 | expect(SpecHelper.gets_with_timeout(client)) 321 | .to eql("150 Listing status ok, about to open data connection\r\n") 322 | data = SpecHelper.gets_with_timeout(data_client, endwith: "\0") 323 | data_client.close 324 | expect(data).to eql([ 325 | SpecHelper.statline(server.file(file_prefix + 'some_file')), 326 | SpecHelper.statline(server.file(file_prefix + 'another_file')) 327 | ].join("\n")) 328 | expect(SpecHelper.gets_with_timeout(client)) 329 | .to eql("226 List information transferred\r\n") 330 | end 331 | 332 | it 'accepts a LIST command with a wildcard argument' do 333 | infiles = %w[test.jpg test-2.jpg test.txt].map do |f| 334 | "#{file_prefix}#{f}" 335 | end 336 | infiles.each do |f| 337 | server.add_file(f, '1234567890') 338 | end 339 | 340 | client.write("LIST *.jpg\r\n") 341 | expect(SpecHelper.gets_with_timeout(client)) 342 | .to eql("150 Listing status ok, about to open data connection\r\n") 343 | 344 | data = SpecHelper.gets_with_timeout(data_client, endwith: "\0") 345 | data_client.close 346 | expect(data).to eql( 347 | infiles[0, 2].map do |f| 348 | SpecHelper.statline(server.file(f)) 349 | end.join("\n") 350 | ) 351 | expect(SpecHelper.gets_with_timeout(client)) 352 | .to eql("226 List information transferred\r\n") 353 | end 354 | 355 | it 'accepts a LIST command with multiple wildcard arguments' do 356 | infiles = %w[test.jpg test.gif test.txt].map do |f| 357 | "#{file_prefix}#{f}" 358 | end 359 | infiles.each do |file| 360 | server.add_file(file, '1234567890') 361 | end 362 | 363 | client.write("LIST *.jpg *.gif\r\n") 364 | expect(SpecHelper.gets_with_timeout(client)) 365 | .to eql("150 Listing status ok, about to open data connection\r\n") 366 | 367 | data = SpecHelper.gets_with_timeout(data_client, endwith: "\0") 368 | data_client.close 369 | expect(data).to eql( 370 | infiles[0, 2].map do |f| 371 | SpecHelper.statline(server.file(f)) 372 | end.join("\n") 373 | ) 374 | expect(SpecHelper.gets_with_timeout(client)) 375 | .to eql("226 List information transferred\r\n") 376 | end 377 | 378 | it 'accepts an NLST command' do 379 | server.add_file('some_file', '1234567890') 380 | server.add_file('another_file', '1234567890') 381 | client.write("NLST\r\n") 382 | expect(SpecHelper.gets_with_timeout(client)) 383 | .to eql("150 Listing status ok, about to open data connection\r\n") 384 | data = SpecHelper.gets_with_timeout(data_client, endwith: "\0") 385 | data_client.close 386 | expect(data).to eql("some_file\nanother_file\n") 387 | expect(SpecHelper.gets_with_timeout(client)) 388 | .to eql("226 List information transferred\r\n") 389 | end 390 | 391 | it 'accepts an NLST command with wildcard arguments' do 392 | files = ['test.jpg', 'test.txt', 'test2.jpg'] 393 | files.each do |file| 394 | server.add_file(file, '1234567890') 395 | end 396 | 397 | client.write("NLST *.jpg\r\n") 398 | 399 | expect(SpecHelper.gets_with_timeout(client)) 400 | .to eql("150 Listing status ok, about to open data connection\r\n") 401 | data = SpecHelper.gets_with_timeout(data_client, endwith: "\0") 402 | data_client.close 403 | 404 | expect(data).to eql("test.jpg\ntest2.jpg\n") 405 | expect(SpecHelper.gets_with_timeout(client)) 406 | .to eql("226 List information transferred\r\n") 407 | end 408 | 409 | it 'should allow mdtm' do 410 | filename = file_prefix + 'file.txt' 411 | now = Time.now 412 | server.add_file(filename, 'some dummy content', now) 413 | client.write("MDTM #{filename}\r\n") 414 | expect(SpecHelper.gets_with_timeout(client)) 415 | .to eql("213 #{now.strftime('%Y%m%d%H%M%S')}\r\n") 416 | end 417 | end 418 | 419 | context 'active' do 420 | before :each do 421 | data_server.start 422 | end 423 | 424 | after :each do 425 | data_server.stop 426 | end 427 | 428 | it 'creates a directory on MKD' do 429 | client.write("MKD some_dir\r\n") 430 | expect(SpecHelper.gets_with_timeout(client)).to eql("257 OK!\r\n") 431 | end 432 | 433 | it 'should save the directory after you CWD' do 434 | client.write("CWD /somewhere/else\r\n") 435 | expect(SpecHelper.gets_with_timeout(client)).to eql("250 OK!\r\n") 436 | client.write("PWD\r\n") 437 | expect(SpecHelper.gets_with_timeout(client)) 438 | .to eql("257 \"/somewhere/else\" is current directory\r\n") 439 | end 440 | 441 | it 'CWD should add a / to the beginning of the directory' do 442 | client.write("CWD somewhere/else\r\n") 443 | expect(SpecHelper.gets_with_timeout(client)).to eql("250 OK!\r\n") 444 | client.write("PWD\r\n") 445 | expect(SpecHelper.gets_with_timeout(client)) 446 | .to eql("257 \"/somewhere/else\" is current directory\r\n") 447 | end 448 | 449 | it 'should not change the directory on CDUP' do 450 | client.write("CDUP\r\n") 451 | expect(SpecHelper.gets_with_timeout(client)).to eql("250 OK!\r\n") 452 | client.write("PWD\r\n") 453 | expect(SpecHelper.gets_with_timeout(client)) 454 | .to eql("257 \"/pub\" is current directory\r\n") 455 | end 456 | 457 | it 'sends error message if no PORT received' do 458 | client.write("STOR some_file\r\n") 459 | expect(SpecHelper.gets_with_timeout(client)) 460 | .to eql("425 Ain't no data port!\r\n") 461 | end 462 | 463 | it 'accepts STOR with filename' do 464 | client.write("PORT #{data_server.addr_bits}\r\n") 465 | expect(SpecHelper.gets_with_timeout(client)).to eql("200 Okay\r\n") 466 | 467 | client.write("STOR some_other_file\r\n") 468 | expect(SpecHelper.gets_with_timeout(client)).to eql("125 Do it!\r\n") 469 | 470 | data_server.handler_sock.print('12345') 471 | data_server.handler_sock.close 472 | 473 | expect(SpecHelper.gets_with_timeout(client)).to eql("226 Did it!\r\n") 474 | expect(server.files).to include(file_prefix + 'some_other_file') 475 | expect(server.file(file_prefix + 'some_other_file').bytes).to eql(5) 476 | end 477 | 478 | it 'accepts RETR with a filename' do 479 | client.write("PORT #{data_server.addr_bits}\r\n") 480 | data_server.handler_sock 481 | expect(SpecHelper.gets_with_timeout(client)).to eql("200 Okay\r\n") 482 | 483 | server.add_file(file_prefix + 'some_file', '1234567890') 484 | client.write("RETR #{file_prefix}some_file\r\n") 485 | expect(SpecHelper.gets_with_timeout(client)) 486 | .to eql("150 File status ok, about to open data connection\r\n") 487 | 488 | data = SpecHelper.gets_with_timeout( 489 | data_server.handler_sock, endwith: "\0" 490 | ) 491 | data_server.handler_sock.close 492 | 493 | expect(data).to eql('1234567890') 494 | expect(SpecHelper.gets_with_timeout(client)) 495 | .to eql("226 File transferred\r\n") 496 | end 497 | 498 | it 'accepts RNFR without filename' do 499 | client.write("RNFR\r\n") 500 | expect(SpecHelper.gets_with_timeout(client)) 501 | .to eql("501 Send path name.\r\n") 502 | end 503 | 504 | it 'accepts RNTO without RNFR' do 505 | client.write("RNTO some_other_file\r\n") 506 | expect(SpecHelper.gets_with_timeout(client)) 507 | .to eql("503 Send RNFR first.\r\n") 508 | end 509 | 510 | it 'accepts RNTO and RNFR without filename' do 511 | client.write("RNFR from_file\r\n") 512 | expect(SpecHelper.gets_with_timeout(client)) 513 | .to eql("350 Send RNTO to complete rename.\r\n") 514 | 515 | client.write("RNTO\r\n") 516 | expect(SpecHelper.gets_with_timeout(client)) 517 | .to eql("501 Send path name.\r\n") 518 | end 519 | 520 | it 'accepts RNTO and RNFR for not existing file' do 521 | client.write("RNFR from_file\r\n") 522 | expect(SpecHelper.gets_with_timeout(client)) 523 | .to eql("350 Send RNTO to complete rename.\r\n") 524 | 525 | client.write("RNTO to_file\r\n") 526 | expect(SpecHelper.gets_with_timeout(client)) 527 | .to eql("550 File not found.\r\n") 528 | end 529 | 530 | it 'accepts RNTO and RNFR' do 531 | server.add_file(file_prefix + 'from_file', '1234567890') 532 | 533 | client.write("RNFR from_file\r\n") 534 | expect(SpecHelper.gets_with_timeout(client)) 535 | .to eql("350 Send RNTO to complete rename.\r\n") 536 | 537 | client.write("RNTO to_file\r\n") 538 | expect(SpecHelper.gets_with_timeout(client)) 539 | .to eql("250 Path renamed.\r\n") 540 | 541 | expect(server.files).to include(file_prefix + 'to_file') 542 | expect(server.files).to_not include(file_prefix + 'from_file') 543 | end 544 | 545 | it 'accepts an NLST command' do 546 | client.write("PORT #{data_server.addr_bits}\r\n") 547 | data_server.handler_sock 548 | expect(SpecHelper.gets_with_timeout(client)).to eql("200 Okay\r\n") 549 | 550 | server.add_file('some_file', '1234567890') 551 | server.add_file('another_file', '1234567890') 552 | client.write("NLST\r\n") 553 | expect(SpecHelper.gets_with_timeout(client)) 554 | .to eql("150 Listing status ok, about to open data connection\r\n") 555 | 556 | data = SpecHelper.gets_with_timeout( 557 | data_server.handler_sock, endwith: "\0" 558 | ) 559 | data_server.handler_sock.close 560 | 561 | expect(data).to eql("some_file\nanother_file\n") 562 | expect(SpecHelper.gets_with_timeout(client)) 563 | .to eql("226 List information transferred\r\n") 564 | end 565 | 566 | it 'has a flavor' do 567 | client.write("PORT #{data_server.addr_bits}\r\n") 568 | data_server.handler_sock 569 | expect(SpecHelper.gets_with_timeout(client)).to eql("200 Okay\r\n") 570 | 571 | client.write("WAT\r\n") 572 | expect(SpecHelper.gets_with_timeout(client)) 573 | .to eql("418 Pizza Party\r\n") 574 | 575 | data = SpecHelper.gets_with_timeout( 576 | data_server.handler_sock, endwith: "\0" 577 | ) 578 | data_server.handler_sock.close 579 | expect(data).to_not be_nil 580 | expect(data).to_not be_empty 581 | end 582 | end 583 | end 584 | end 585 | end 586 | -------------------------------------------------------------------------------- /spec/integration/server_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'net/ftp' 4 | 5 | describe FakeFtp::Server, 'with ftp client', integration: true do 6 | let(:server) { FakeFtp::Server.new(21_212, 21_213, absolute: true) } 7 | let(:client) { Net::FTP.new(nil, debug_mode: ENV['DEBUG'] == '1') } 8 | let(:text_filename) do 9 | File.expand_path('../../fixtures/text_file.txt', __FILE__) 10 | end 11 | 12 | before { server.start } 13 | 14 | after :each do 15 | client.close 16 | server.stop 17 | end 18 | 19 | it 'should accept connections' do 20 | expect { client.connect('127.0.0.1', 21_212) }.to_not raise_error 21 | end 22 | 23 | context 'with client' do 24 | before { client.connect('127.0.0.1', 21_212) } 25 | 26 | it 'should allow anonymous authentication' do 27 | expect { client.login }.to_not raise_error 28 | end 29 | 30 | it 'should allow named authentication' do 31 | expect { client.login('someone', 'password') }.to_not raise_error 32 | end 33 | 34 | it 'should allow client to quit' do 35 | expect { client.login('someone', 'password') }.to_not raise_error 36 | expect { client.quit }.to_not raise_error 37 | end 38 | 39 | it 'should allow mtime' do 40 | filename = '/pub/someone' 41 | time = Time.now 42 | server.add_file(filename, 'some data', time) 43 | 44 | client.passive = false 45 | mtime = client.mtime(filename) 46 | expect(mtime.to_s).to eql(time.to_s) 47 | 48 | client.passive = true 49 | mtime = client.mtime(filename) 50 | expect(mtime.to_s).to eql(time.to_s) 51 | end 52 | 53 | it 'should put files using PASV' do 54 | expect(File.stat(text_filename).size).to eql(20) 55 | 56 | client.passive = true 57 | expect { client.put(text_filename) }.to_not raise_error 58 | 59 | expect(server.files).to include('/pub/text_file.txt') 60 | expect(server.file('/pub/text_file.txt').bytes).to eql(20) 61 | expect(server.file('/pub/text_file.txt')).to be_passive 62 | expect(server.file('/pub/text_file.txt')).to_not be_active 63 | end 64 | 65 | it 'should put files using active' do 66 | expect(File.stat(text_filename).size).to eql(20) 67 | 68 | client.passive = false 69 | expect { client.put(text_filename) }.to_not raise_error 70 | 71 | expect(server.files).to include('/pub/text_file.txt') 72 | expect(server.file('/pub/text_file.txt').bytes).to eql(20) 73 | expect(server.file('/pub/text_file.txt')).to_not be_passive 74 | expect(server.file('/pub/text_file.txt')).to be_active 75 | end 76 | 77 | it 'should allow client to execute SITE command' do 78 | expect { client.site('umask') }.to_not raise_error 79 | end 80 | 81 | it 'should be able to delete files added using put' do 82 | expect(File.stat(text_filename).size).to eql(20) 83 | 84 | client.passive = false 85 | expect { client.put(text_filename) }.to_not raise_error 86 | expect(server.files).to include('/pub/text_file.txt') 87 | expect { client.delete(text_filename) }.to_not raise_error 88 | expect(server.files).to_not include('/pub/text_file.txt') 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/models/fake_ftp/file_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe FakeFtp::File do 4 | context 'attributes' do 5 | before :each do 6 | @file = FakeFtp::File.new 7 | end 8 | 9 | it 'has a name attribute' do 10 | @file.name = 'some name' 11 | expect(@file.name).to eql('some name') 12 | end 13 | 14 | it 'has a last_modified_time attribute' do 15 | now = Time.now 16 | @file.last_modified_time = now 17 | expect(@file.last_modified_time).to eql(now) 18 | end 19 | 20 | it 'has a bytes attribute' do 21 | @file.bytes = 87 22 | expect(@file.bytes).to eql(87) 23 | end 24 | 25 | it 'has a data attribute' do 26 | @file.data = 'some data' 27 | expect(@file.data).to eql('some data') 28 | expect(@file.bytes).to eql(9) 29 | end 30 | end 31 | 32 | context 'setup' do 33 | it 'can be initialized without attributes' do 34 | file = FakeFtp::File.new 35 | expect(file.name).to be_nil 36 | expect(file.bytes).to be_nil 37 | expect(file.instance_variable_get(:@type)).to be_nil 38 | end 39 | 40 | it 'can be initialized with name' do 41 | file = FakeFtp::File.new('filename') 42 | expect(file.name).to eql('filename') 43 | expect(file.bytes).to be_nil 44 | expect(file.instance_variable_get(:@type)).to be_nil 45 | end 46 | 47 | it 'can be initialized with name and bytes' do 48 | file = FakeFtp::File.new('filename', 104) 49 | expect(file.name).to eql('filename') 50 | expect(file.bytes).to eql(104) 51 | expect(file.instance_variable_get(:@type)).to be_nil 52 | end 53 | 54 | it 'can be initialized with name and bytes and type' do 55 | file = FakeFtp::File.new('filename', 104, :passive) 56 | expect(file.name).to eql('filename') 57 | expect(file.bytes).to eql(104) 58 | expect(file.instance_variable_get(:@type)).to eql(:passive) 59 | end 60 | 61 | it 'can be initialized with name and bytes and type ' \ 62 | 'and last_modified_time' do 63 | time = Time.now 64 | file = FakeFtp::File.new('filename', 104, :passive, time) 65 | expect(file.name).to eql('filename') 66 | expect(file.bytes).to eql(104) 67 | expect(file.instance_variable_get(:@type)).to eql(:passive) 68 | expect(file.last_modified_time).to eql(time) 69 | end 70 | end 71 | 72 | describe '#passive?' do 73 | before :each do 74 | @file = FakeFtp::File.new 75 | end 76 | 77 | it 'should be true if type is :passive' do 78 | @file.type = :passive 79 | expect(@file.passive?).to be true 80 | end 81 | 82 | it 'should be false if type is :active' do 83 | @file.type = :active 84 | expect(@file.passive?).to be false 85 | end 86 | end 87 | 88 | describe '#active?' do 89 | before :each do 90 | @file = FakeFtp::File.new 91 | end 92 | 93 | it 'should be true if type is :active' do 94 | @file.type = :active 95 | expect(@file.active?).to be true 96 | end 97 | 98 | it 'should be false if type is :passive' do 99 | @file.type = :passive 100 | expect(@file.active?).to be false 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /spec/models/fake_ftp/server_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe FakeFtp::Server, 'setup' do 4 | it 'starts a server on port n' do 5 | server = FakeFtp::Server.new(21_212) 6 | expect(server.port).to eql(21_212) 7 | end 8 | 9 | it 'should defaults to port 21' do 10 | server = FakeFtp::Server.new 11 | expect(server.port).to eql(21) 12 | end 13 | 14 | it 'starts a passive server on port p' do 15 | server = FakeFtp::Server.new(21_212, 21_213) 16 | expect(server.passive_port).to eql(21_213) 17 | end 18 | 19 | it 'should start and stop' do 20 | server = FakeFtp::Server.new(21_212) 21 | expect(server.running?).to be false 22 | server.start 23 | expect(server.running?).to be true 24 | server.stop 25 | expect(server.running?).to be false 26 | end 27 | 28 | it 'should default :mode to :active' do 29 | server = FakeFtp::Server.new(21_212, 21_213) 30 | expect(server.mode).to eql(:active) 31 | end 32 | 33 | it 'should start and stop passive port' do 34 | server = FakeFtp::Server.new(21_212, 21_213) 35 | expect(server.running?(21_213)).to be false 36 | server.start 37 | expect(server.running?(21_213)).to be true 38 | server.stop 39 | expect(server.running?(21_213)).to be false 40 | end 41 | 42 | it 'should raise if attempting to use a bound port' do 43 | server = FakeFtp::Server.new(21_212) 44 | server.start 45 | expect { FakeFtp::Server.new(21_212) } 46 | .to raise_error(Errno::EADDRINUSE, 'Address already in use - 21212') 47 | server.stop 48 | end 49 | 50 | it 'should raise if attempting to use a bound passive_port' do 51 | server = FakeFtp::Server.new(21_212, 21_213) 52 | server.start 53 | expect { FakeFtp::Server.new(21_214, 21_213) } 54 | .to raise_error(Errno::EADDRINUSE, 'Address already in use - 21213') 55 | server.stop 56 | end 57 | end 58 | 59 | describe FakeFtp::Server, 'files' do 60 | let(:file) { FakeFtp::File.new('filename', 34) } 61 | let(:server) { FakeFtp::Server.new(21_212) } 62 | 63 | before { server.instance_variable_set(:@store, '/pub/filename' => file) } 64 | 65 | it 'returns filenames from :files' do 66 | expect(server.files).to include('filename') 67 | end 68 | 69 | it 'can be accessed with :file' do 70 | expect(server.file('filename')).to eql(file) 71 | end 72 | 73 | it 'can reset files' do 74 | server.reset 75 | expect(server.files).to eql([]) 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift(File.expand_path('../lib', __FILE__)) 4 | 5 | require 'simplecov' 6 | require 'rspec' 7 | require 'fake_ftp' 8 | 9 | RSpec.configure do |c| 10 | c.filter_run_excluding( 11 | functional: ENV['FUNCTIONAL_SPECS'] != '1', 12 | integration: ENV['INTEGRATION_SPECS'] != '1' 13 | ) 14 | end 15 | 16 | module SpecHelper 17 | def gets_with_timeout(client, timeout: 5, endwith: "\r\n", chunk: 1024) 18 | outer_caller = caller(0..1).last.to_s 19 | start = Time.now 20 | buf = '' 21 | loop do 22 | if Time.now - start >= timeout 23 | raise Timeout::Error, "client=#{client} timeout=#{timeout}s " \ 24 | "buf=#{buf.inspect} caller=#{outer_caller.inspect}" 25 | end 26 | bytes = client.read_nonblock(chunk, exception: false) 27 | return buf if bytes.nil? 28 | buf += bytes unless bytes == :wait_readable 29 | return buf if buf.end_with?(endwith) 30 | end 31 | buf 32 | end 33 | 34 | module_function :gets_with_timeout 35 | 36 | def local_addr_bits(port) 37 | [ 38 | 127, 0, 0, 1, 39 | port / 256, 40 | port % 256 41 | ].map(&:to_s).join(',') 42 | end 43 | 44 | module_function :local_addr_bits 45 | 46 | def statline(ftp_file) 47 | %W[ 48 | -rw-r--r-- 49 | 1 50 | owner 51 | group 52 | 10 53 | #{ftp_file.created.strftime('%b %d %H:%M')} 54 | #{ftp_file.name} 55 | ].join("\t") 56 | end 57 | 58 | module_function :statline 59 | 60 | class FakeDataServer 61 | def initialize(port) 62 | @port = port 63 | end 64 | 65 | attr_reader :port 66 | 67 | def addr_bits 68 | ::SpecHelper.local_addr_bits(port) 69 | end 70 | 71 | def start 72 | server 73 | end 74 | 75 | def stop 76 | server.close 77 | end 78 | 79 | def handler_sock 80 | @handler_sock ||= wait_for_handler_sock 81 | end 82 | 83 | private 84 | 85 | def wait_for_handler_sock 86 | sock = nil 87 | 88 | while sock.nil? || sock == :wait_readable 89 | sleep 0.01 90 | sock = server.accept_nonblock(exception: false) 91 | end 92 | 93 | sock 94 | end 95 | 96 | def server 97 | @server ||= TCPServer.new('127.0.0.1', port).tap do |srv| 98 | srv.sync = true 99 | end 100 | end 101 | end 102 | end 103 | --------------------------------------------------------------------------------