fake_ftp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.codeclimate.yml +16 -0
  3. data/.gitignore +1 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +27 -0
  6. data/.rubocop_todo.yml +7 -0
  7. data/.simplecov +6 -0
  8. data/.travis.yml +27 -2
  9. data/CHANGELOG.md +136 -0
  10. data/Gemfile +9 -7
  11. data/Guardfile +5 -4
  12. data/LICENSE.md +20 -0
  13. data/README.md +30 -63
  14. data/Rakefile +12 -7
  15. data/fake_ftp.gemspec +16 -17
  16. data/lib/fake_ftp.rb +3 -2
  17. data/lib/fake_ftp/file.rb +11 -5
  18. data/lib/fake_ftp/server.rb +138 -261
  19. data/lib/fake_ftp/server_commands.rb +6 -0
  20. data/lib/fake_ftp/server_commands/acct.rb +11 -0
  21. data/lib/fake_ftp/server_commands/cdup.rb +11 -0
  22. data/lib/fake_ftp/server_commands/cwd.rb +13 -0
  23. data/lib/fake_ftp/server_commands/dele.rb +25 -0
  24. data/lib/fake_ftp/server_commands/list.rb +39 -0
  25. data/lib/fake_ftp/server_commands/mdtm.rb +17 -0
  26. data/lib/fake_ftp/server_commands/mkd.rb +11 -0
  27. data/lib/fake_ftp/server_commands/nlst.rb +32 -0
  28. data/lib/fake_ftp/server_commands/pass.rb +11 -0
  29. data/lib/fake_ftp/server_commands/pasv.rb +15 -0
  30. data/lib/fake_ftp/server_commands/port.rb +23 -0
  31. data/lib/fake_ftp/server_commands/pwd.rb +11 -0
  32. data/lib/fake_ftp/server_commands/quit.rb +13 -0
  33. data/lib/fake_ftp/server_commands/retr.rb +32 -0
  34. data/lib/fake_ftp/server_commands/rnfr.rb +18 -0
  35. data/lib/fake_ftp/server_commands/rnto.rb +22 -0
  36. data/lib/fake_ftp/server_commands/site.rb +11 -0
  37. data/lib/fake_ftp/server_commands/size.rb +11 -0
  38. data/lib/fake_ftp/server_commands/stor.rb +30 -0
  39. data/lib/fake_ftp/server_commands/type.rb +18 -0
  40. data/lib/fake_ftp/server_commands/user.rb +12 -0
  41. data/lib/fake_ftp/server_commands/wat.rb +33 -0
  42. data/lib/fake_ftp/version.rb +3 -1
  43. data/spec/functional/server_spec.rb +459 -358
  44. data/spec/integration/server_spec.rb +39 -29
  45. data/spec/models/fake_ftp/file_spec.rb +22 -21
  46. data/spec/models/fake_ftp/server_spec.rb +33 -32
  47. data/spec/spec_helper.rb +98 -14
  48. metadata +32 -3
data/Rakefile CHANGED
@@ -1,10 +1,15 @@
1
- require 'bundler'
2
- Bundler::GemHelper.install_tasks
1
+ # frozen_string_literal: true
3
2
 
4
- require 'rspec/core'
5
- require 'rspec/core/rake_task'
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
6
10
 
7
- RSpec::Core::RakeTask.new('spec')
11
+ Bundler::GemHelper.install_tasks if defined?(Bundler)
12
+ RSpec::Core::RakeTask.new if defined?(RSpec)
13
+ RuboCop::RakeTask.new if defined?(RuboCop)
8
14
 
9
- # If you want to make this the default task
10
- task :default => :spec
15
+ task default: %i[rubocop spec]
@@ -1,22 +1,21 @@
1
1
  # -*- encoding: utf-8 -*-
2
- $:.push File.expand_path("../lib", __FILE__)
3
- require "fake_ftp/version"
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path('../lib', __FILE__))
5
+ require 'fake_ftp/version'
4
6
 
5
7
  Gem::Specification.new do |s|
6
- s.name = "fake_ftp"
7
- s.version = FakeFtp::VERSION
8
- s.platform = Gem::Platform::RUBY
9
- s.authors = ["Colin Shield", "Eric Saxby"]
10
- s.email = ["sax+github@livinginthepast.org"]
11
- s.homepage = "http://rubygems.org/gems/fake_ftp"
12
- s.summary = %q{Creates a fake FTP server for use in testing}
13
- s.description = %q{Testing FTP? Use this!}
14
- s.license = 'MIT'
8
+ s.name = 'fake_ftp'
9
+ s.version = FakeFtp::VERSION
10
+ s.platform = Gem::Platform::RUBY
11
+ s.authors = ['Colin Shield', 'Eric Saxby']
12
+ s.email = ['sax+github@livinginthepast.org']
13
+ s.homepage = 'http://rubygems.org/gems/fake_ftp'
14
+ s.summary = 'Creates a fake FTP server for use in testing'
15
+ s.description = 'Testing FTP? Use this!'
16
+ s.license = 'MIT'
15
17
 
16
- s.required_rubygems_version = ">= 1.3.6"
17
-
18
- s.files = `git ls-files`.split("\n")
19
- s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
20
- s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
21
- s.require_paths = ["lib"]
18
+ s.files = `git ls-files -z`.split("\0")
19
+ s.test_files = `git ls-files -z -- spec/*`.split("\0")
20
+ s.require_paths = %w[lib]
22
21
  end
@@ -1,5 +1,6 @@
1
- require 'fake_ftp/server'
2
- require 'fake_ftp/file'
1
+ # frozen_string_literal: true
3
2
 
4
3
  module FakeFtp
4
+ autoload :Server, 'fake_ftp/server'
5
+ autoload :File, 'fake_ftp/file'
5
6
  end
@@ -1,23 +1,29 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module FakeFtp
2
4
  class File
3
- attr_accessor :bytes, :name, :last_modified_time
5
+ attr_accessor :bytes, :data, :name, :last_modified_time
4
6
  attr_writer :type
5
- attr_accessor :data
6
7
  attr_reader :created
7
8
 
8
- def initialize(name = nil, data = nil, type = nil, last_modified_time = Time.now)
9
+ def initialize(name = nil, data = nil, type = nil,
10
+ last_modified_time = Time.now)
9
11
  @created = Time.now
10
12
  @name = name
11
13
  @data = data
12
- # FIXME this is far too ambiguous. args should not mean different
14
+ # FIXME: this is far too ambiguous. args should not mean different
13
15
  # things in different contexts.
14
- data_is_bytes = (data.nil? || Integer === data)
16
+ data_is_bytes = (data.nil? || data.is_a?(Integer))
15
17
  @bytes = data_is_bytes ? data : data.to_s.length
16
18
  @data = data_is_bytes ? nil : data
17
19
  @type = type
18
20
  @last_modified_time = last_modified_time.utc
19
21
  end
20
22
 
23
+ def basename
24
+ ::File.basename(@name)
25
+ end
26
+
21
27
  def data=(data)
22
28
  @data = data
23
29
  @bytes = @data.nil? ? nil : data.length
@@ -1,63 +1,67 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'socket'
2
4
  require 'thread'
3
5
  require 'timeout'
4
6
 
5
7
  module FakeFtp
6
8
  class Server
9
+ attr_accessor :client, :command_state, :data_server, :passive_port
10
+ attr_accessor :port, :store, :workdir
7
11
 
8
- attr_accessor :port, :passive_port
9
- attr_reader :mode, :path
10
-
11
- CMDS = %w(
12
- acct
13
- cwd
14
- cdup
15
- dele
16
- list
17
- mdtm
18
- mkd
19
- nlst
20
- pass
21
- pasv
22
- port
23
- pwd
24
- quit
25
- size
26
- stor
27
- retr
28
- rnfr
29
- rnto
30
- type
31
- user
32
- )
33
- LNBK = "\r\n"
12
+ alias path workdir
34
13
 
35
14
  def initialize(control_port = 21, data_port = nil, options = {})
36
- self.port = control_port
37
- self.passive_port = data_port
38
- raise(Errno::EADDRINUSE, "#{port}") if !control_port.zero? && is_running?
39
- raise(Errno::EADDRINUSE, "#{passive_port}") if passive_port && !passive_port.zero? && is_running?(passive_port)
40
- @connection = nil
15
+ @port = control_port
16
+ @passive_port = data_port
17
+ @store = {}
18
+ @workdir = '/pub'
41
19
  @options = options
42
- @files = []
43
- @mode = :active
44
- @path = "/pub"
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)
45
35
  end
46
36
 
47
37
  def files
48
- @files.map(&:name)
38
+ @store.values.map do |f|
39
+ if absolute?
40
+ abspath(f.name)
41
+ else
42
+ f.name
43
+ end
44
+ end
49
45
  end
50
46
 
51
47
  def file(name)
52
- @files.detect { |file| file.name == 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
53
55
  end
54
56
 
55
57
  def reset
56
- @files.clear
58
+ @store.clear
57
59
  end
58
60
 
59
61
  def add_file(filename, data, last_modified_time = Time.now)
60
- @files << FakeFtp::File.new(::File.basename(filename.to_s), data, @mode, last_modified_time)
62
+ @store[abspath(filename)] = FakeFtp::File.new(
63
+ filename.to_s, data, options[:mode], last_modified_time
64
+ )
61
65
  end
62
66
 
63
67
  def start
@@ -66,20 +70,36 @@ module FakeFtp
66
70
  @port = @server.addr[1]
67
71
  @thread = Thread.new do
68
72
  while @started
69
- @client = @server.accept rescue nil
70
- if @client
71
- respond_with('220 Can has FTP?')
72
- @connection = Thread.new(@client) do |socket|
73
- while @started && !socket.nil? && !socket.closed?
74
- input = socket.gets rescue nil
75
- respond_with parse(input) if input
76
- end
77
- unless @client.nil?
78
- @client.close unless @client.closed?
79
- @client = nil
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))
80
94
  end
81
95
  end
96
+ unless @client.nil?
97
+ @client.close unless @client.closed?
98
+ @client = nil
99
+ end
100
+ debug('leave request thread')
82
101
  end
102
+ debug('leave client loop')
83
103
  end
84
104
  unless @server.nil?
85
105
  @server.close unless @server.closed?
@@ -87,258 +107,110 @@ module FakeFtp
87
107
  end
88
108
  end
89
109
 
90
- if passive_port
91
- @data_server = ::TCPServer.new('127.0.0.1', passive_port)
92
- @passive_port = @data_server.addr[1]
93
- end
110
+ return unless passive_port
111
+ @data_server = ::TCPServer.new('127.0.0.1', passive_port)
112
+ @passive_port = @data_server.addr[1]
94
113
  end
95
114
 
96
115
  def stop
97
116
  @started = false
98
- @client.close if @client
99
- @server.close if @server
117
+ @client&.close
118
+ @server&.close
100
119
  @server = nil
101
- @data_server.close if @data_server
120
+ @data_server&.close
102
121
  @data_server = nil
103
122
  end
104
123
 
105
- def is_running?(tcp_port = nil)
106
- tcp_port.nil? ? port_is_open?(port) : port_is_open?(tcp_port)
107
- end
108
-
109
- private
110
-
111
- def respond_with(stuff)
112
- @client.print stuff << LNBK unless stuff.nil? or @client.nil? or @client.closed?
113
- end
114
-
115
- def parse(request)
116
- return if request.nil?
117
- puts request if @options[:debug]
118
- command = request[0, 4].downcase.strip
119
- contents = request.split
120
- message = contents[1..contents.length]
121
- case command
122
- when *CMDS
123
- __send__ "_#{command}", *message
124
- else
125
- '500 Unknown command'
126
- end
127
- end
128
-
129
-
130
- ## FTP commands
131
- #
132
- # Methods are prefixed with an underscore to avoid conflicts with internal server
133
- # methods. Methods map 1:1 to FTP command words.
134
- #
135
- def _acct(*args)
136
- '230 WHATEVER!'
137
- end
138
-
139
- def _cwd(*args)
140
- @path = args[0]
141
- @path = "/#{path}" if path[0].chr != "/"
142
- '250 OK!'
143
- end
144
-
145
- def _cdup(*args)
146
- '250 OK!'
147
- end
148
-
149
- def _list(*args)
150
- respond_with('425 Ain\'t no data port!') && return if active? && @active_connection.nil?
151
-
152
- respond_with('150 Listing status ok, about to open data connection')
153
- data_client = active? ? @active_connection : @data_server.accept
154
-
155
- wildcards = build_wildcards(args)
156
- files = matching_files(@files, wildcards)
157
-
158
- files = files.map do |f|
159
- "-rw-r--r--\t1\towner\tgroup\t#{f.bytes}\t#{f.created.strftime('%b %d %H:%M')}\t#{f.name}\n"
160
- end
161
- data_client.write(files.join)
162
- data_client.close
163
- @active_connection = nil
164
-
165
- '226 List information transferred'
166
- end
167
-
168
- def _mdtm(filename = '', local = false)
169
- respond_with('501 No filename given') && return if filename.empty?
170
- server_file = file(filename)
171
- respond_with('550 File not found') && return if server_file.nil?
172
-
173
- respond_with("213 #{server_file.last_modified_time.strftime("%Y%m%d%H%M%S")}")
124
+ def running?(tcp_port = nil)
125
+ return port_is_open?(port) if tcp_port.nil?
126
+ port_is_open?(tcp_port)
174
127
  end
175
128
 
176
- def _nlst(*args)
177
- respond_with('425 Ain\'t no data port!') && return if active? && @active_connection.nil?
178
-
179
- respond_with('150 Listing status ok, about to open data connection')
180
- data_client = active? ? @active_connection : @data_server.accept
129
+ alias is_running? running?
181
130
 
182
- wildcards = build_wildcards(args)
183
- files = matching_files(@files, wildcards)
184
-
185
- files = files.map do |file|
186
- "#{file.name}\n"
131
+ def mode=(value)
132
+ unless %i[active passive].include?(value)
133
+ raise ArgumentError, "invalid mode #{value.inspect}"
187
134
  end
188
-
189
- data_client.write(files.join)
190
- data_client.close
191
- @active_connection = nil
192
-
193
- '226 List information transferred'
135
+ options[:mode] = value
194
136
  end
195
137
 
196
- def _pass(*args)
197
- '230 logged in'
138
+ def mode
139
+ options[:mode]
198
140
  end
199
141
 
200
- def _pasv(*args)
201
- if passive_port
202
- @mode = :passive
203
- p1 = (passive_port / 256).to_i
204
- p2 = passive_port % 256
205
- "227 Entering Passive Mode (127,0,0,1,#{p1},#{p2})"
206
- else
207
- '502 Aww hell no, use Active'
208
- end
142
+ def absolute?
143
+ options[:absolute]
209
144
  end
210
145
 
211
- def _port(remote = '')
212
- remote = remote.split(',')
213
- remote_port = remote[4].to_i * 256 + remote[5].to_i
214
- unless @active_connection.nil?
215
- @active_connection.close
216
- @active_connection = nil
146
+ def absolute=(value)
147
+ unless [true, false].include?(value)
148
+ raise ArgumentError, "invalid absolute #{value}"
217
149
  end
218
- @mode = :active
219
- @active_connection = ::TCPSocket.open('127.0.0.1', remote_port)
220
- '200 Okay'
150
+ options[:absolute] = value
221
151
  end
222
152
 
223
- def _pwd(*args)
224
- "257 \"#{path}\" is current directory"
225
- end
153
+ attr_reader :options
154
+ private :options
226
155
 
227
- def _quit(*args)
228
- respond_with '221 OMG bye!'
229
- @client.close if @client
230
- @client = nil
156
+ def abspath(filename)
157
+ return filename if filename.start_with?('/')
158
+ [@workdir.to_s, filename].join('/').gsub('//', '/')
231
159
  end
232
160
 
233
- def _retr(filename = '')
234
- respond_with('501 No filename given') if filename.empty?
235
-
236
- file = file(::File.basename(filename.to_s))
237
- return respond_with('550 File not found') if file.nil?
238
-
239
- respond_with('425 Ain\'t no data port!') && return if active? && @active_connection.nil?
240
-
241
- respond_with('150 File status ok, about to open data connection')
242
- data_client = active? ? @active_connection : @data_server.accept
243
-
244
- data_client.write(file.data)
245
-
246
- data_client.close
247
- @active_connection = nil
248
- '226 File transferred'
249
- end
250
-
251
- def _rnfr(rename_from='')
252
- return '501 Send path name.' if rename_from.nil? || rename_from.size < 1
253
-
254
- @rename_from = rename_from
255
- '350 Send RNTO to complete rename.'
256
- end
257
-
258
- def _rnto(rename_to='')
259
- return '501 Send path name.' if rename_to.nil? || rename_to.size < 1
260
-
261
- return '503 Send RNFR first.' unless @rename_from
262
-
263
- if file = file(@rename_from)
264
- file.name = rename_to
265
- @rename_from = nil
266
- '250 Path renamed.'
267
- else
268
- @rename_from = nil
269
- '550 File not found.'
270
- end
271
- end
272
-
273
- def _size(filename)
274
- file_size = file(filename).bytes
275
- respond_with("213 #{file_size}")
276
- end
277
-
278
- def _stor(filename = '')
279
- respond_with('425 Ain\'t no data port!') && return if active? && @active_connection.nil?
280
-
281
- respond_with('125 Do it!')
282
- data_client = active? ? @active_connection : @data_server.accept
283
-
284
- data = data_client.read(nil).chomp
285
- file = FakeFtp::File.new(::File.basename(filename.to_s), data, @mode)
286
- @files << file
287
-
288
- data_client.close
289
- @active_connection = nil
290
- '226 Did it!'
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")
291
165
  end
292
166
 
293
- def _dele(filename = '')
294
- files_to_delete = @files.select{ |file| file.name == filename }
295
- return '550 Delete operation failed.' if files_to_delete.count == 0
296
-
297
- @files = @files - files_to_delete
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]
298
173
 
299
- '250 Delete operation successful.'
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))
300
181
  end
301
182
 
302
- def _type(type = 'A')
303
- case type.to_s
304
- when 'A'
305
- '200 Type set to A.'
306
- when 'I'
307
- '200 Type set to I.'
308
- else
309
- '504 We don\'t allow those'
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
310
188
  end
311
- end
312
-
313
- def _user(name = '')
314
- (name.to_s == 'anonymous') ? '230 logged in' : '331 send your password'
315
- end
316
-
317
- def _mkd(directory)
318
- "257 OK!"
189
+ nil
190
+ rescue LoadError => e
191
+ debug("failed to require #{command.inspect} class: #{e}")
192
+ nil
319
193
  end
320
194
 
321
195
  def active?
322
- @mode == :active
196
+ options[:mode] == :active
323
197
  end
324
198
 
325
- private
326
-
327
- def port_is_open?(port)
199
+ private def port_is_open?(port)
328
200
  begin
329
- Timeout::timeout(1) do
201
+ Timeout.timeout(1) do
330
202
  begin
331
- s = TCPSocket.new("127.0.0.1", port)
332
- s.close
203
+ TCPSocket.new('127.0.0.1', port).close
333
204
  return true
334
205
  rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
335
206
  return false
336
207
  end
337
208
  end
338
- rescue Timeout::Error
209
+ rescue Timeout::Error => e
210
+ debug("timeout while checking port #{port}: #{e}")
339
211
  end
340
212
 
341
- return false
213
+ false
342
214
  end
343
215
 
344
216
  def build_wildcards(args)
@@ -350,14 +222,19 @@ module FakeFtp
350
222
  wildcards
351
223
  end
352
224
 
353
- def matching_files(files, wildcards)
354
- if not wildcards.empty?
355
- files.select do |f|
225
+ def matching_files(wildcards)
226
+ if !wildcards.empty?
227
+ @store.values.select do |f|
356
228
  wildcards.any? { |wildcard| f.name =~ /#{wildcard}/ }
357
229
  end
358
230
  else
359
- files
231
+ @store.values
360
232
  end
361
233
  end
234
+
235
+ def debug(msg)
236
+ return unless options[:debug]
237
+ $stderr.puts("DEBUG:fake_ftp:#{msg}")
238
+ end
362
239
  end
363
240
  end