testftpd 0.1.1
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.
- data/bin/testftpd +39 -0
- data/lib/testftpd/file_system_provider.rb +81 -0
- data/lib/testftpd/server.rb +44 -0
- data/lib/testftpd/version.rb +3 -0
- data/lib/testftpd.rb +6 -0
- data/spec/fixtures/ftproot/subfolder/test_file1 +1 -0
- data/spec/fixtures/ftproot/subfolder/test_file2 +1 -0
- data/spec/fixtures/ftproot/test_file +1 -0
- data/spec/fixtures/ftproot/test_file_to_delete +1 -0
- data/spec/fixtures/ftproot/test_file_to_rename +1 -0
- data/spec/fixtures/test_file_to_upload +1 -0
- data/spec/integration/test_ftp_spec.rb +174 -0
- data/spec/spec_helper.rb +20 -0
- data/spec/unit/testftpd/server_spec.rb +30 -0
- data/vendor/dyn-ftp-serv/CHANGELOG +15 -0
- data/vendor/dyn-ftp-serv/LICENSE +22 -0
- data/vendor/dyn-ftp-serv/dynftp_server.rb +478 -0
- data/vendor/dyn-ftp-serv/examples/ftpserv.rb +79 -0
- metadata +138 -0
data/bin/testftpd
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require_relative '../lib/testftpd'
|
4
|
+
require 'optparse'
|
5
|
+
|
6
|
+
options = { :root_dir => File.expand_path('.'), :port => 21 }
|
7
|
+
OptionParser.new do |opts|
|
8
|
+
opts.on(
|
9
|
+
'-r', '--root <ROOT_DIR>',
|
10
|
+
'Serve files from <ROOT_DIR>, defaults to the current directory.'
|
11
|
+
) do |root_dir|
|
12
|
+
options[:root_dir] = root_dir
|
13
|
+
end
|
14
|
+
|
15
|
+
opts.on(
|
16
|
+
'-p', '--port <FTP_PORT>',
|
17
|
+
'Listen for connections on <FTP_PORT>, defaults to 21.'
|
18
|
+
) do |ftp_port|
|
19
|
+
options[:port] = ftp_port
|
20
|
+
end
|
21
|
+
end.parse!
|
22
|
+
|
23
|
+
puts 'Starting TestFTPd::Server...'
|
24
|
+
puts "Root Directory: '#{options[:root_dir]}'"
|
25
|
+
puts " TCP Port: #{options[:port]}"
|
26
|
+
|
27
|
+
begin
|
28
|
+
TestFtpd::Server.new(options).start
|
29
|
+
rescue Exception => e
|
30
|
+
puts 'There was a problem starting the FTP process...'
|
31
|
+
puts e.message
|
32
|
+
exit!(255)
|
33
|
+
end
|
34
|
+
|
35
|
+
begin
|
36
|
+
sleep 0.1 while true
|
37
|
+
rescue Interrupt
|
38
|
+
puts 'Interrupted.'
|
39
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
module TestFtpd
|
2
|
+
|
3
|
+
class FileSystemProvider
|
4
|
+
attr_reader :path, :ftp_name, :ftp_size, :ftp_date
|
5
|
+
|
6
|
+
def initialize(path)
|
7
|
+
@path = path
|
8
|
+
@ftp_name = path.split('/').last
|
9
|
+
@ftp_name = '/' unless @ftp_name
|
10
|
+
@ftp_dir = File.directory?(path)
|
11
|
+
@ftp_size = File.size?(path)
|
12
|
+
@ftp_size = 0 unless @ftp_size
|
13
|
+
@ftp_date = Time.now
|
14
|
+
@ftp_date = File.mtime(path) if File.exists?(path)
|
15
|
+
end
|
16
|
+
|
17
|
+
def directory?
|
18
|
+
@ftp_dir
|
19
|
+
end
|
20
|
+
|
21
|
+
def ftp_parent(root = nil)
|
22
|
+
return root if root && root.path == path
|
23
|
+
path_parts = path.split('/')
|
24
|
+
return nil unless path_parts.pop
|
25
|
+
return nil if path_parts.size <= 1
|
26
|
+
FileSystemProvider.new(path_parts.join('/'))
|
27
|
+
end
|
28
|
+
|
29
|
+
def ftp_list(filter = nil)
|
30
|
+
if filter
|
31
|
+
if File.directory?(File.join(path, filter))
|
32
|
+
entries = Dir.entries(File.join(path, filter)).map { |name| File.join(filter, name) }
|
33
|
+
else
|
34
|
+
entries = Dir.glob(File.join(path, filter)).map { |name| File.basename(name) }
|
35
|
+
end
|
36
|
+
else
|
37
|
+
entries = Dir.entries(path)
|
38
|
+
end
|
39
|
+
entries = entries.reject { |name| %w{. ..}.include?(File.basename(name)) }
|
40
|
+
entries.map do |name|
|
41
|
+
FileSystemProvider.new(File.join(path, name))
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def ftp_create(name, dir = false)
|
46
|
+
return FileSystemProvider.new(path + '/' + name) unless dir
|
47
|
+
Dir.mkdir(path + '/' + name)
|
48
|
+
FileSystemProvider.new(path + '/' + name)
|
49
|
+
rescue
|
50
|
+
return false
|
51
|
+
end
|
52
|
+
|
53
|
+
def ftp_retrieve(output)
|
54
|
+
File.open(path, 'r') { |io| output << io.read }
|
55
|
+
end
|
56
|
+
|
57
|
+
def ftp_store(input)
|
58
|
+
return false unless File.open(path, 'w') do |f|
|
59
|
+
f.write input.read
|
60
|
+
end
|
61
|
+
@ftp_size = File.size?(path)
|
62
|
+
@ftp_date = File.mtime(path) if File.exists?(path)
|
63
|
+
end
|
64
|
+
|
65
|
+
def ftp_rename(to_name)
|
66
|
+
to_path = File.join(File.dirname(path), to_name)
|
67
|
+
FileUtils.mv(path, to_path)
|
68
|
+
true
|
69
|
+
end
|
70
|
+
|
71
|
+
def ftp_delete(dir = false)
|
72
|
+
if dir
|
73
|
+
FileUtils.remove_dir(path)
|
74
|
+
else
|
75
|
+
FileUtils.remove_file(path)
|
76
|
+
end
|
77
|
+
true
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require_relative '../../vendor/dyn-ftp-serv/dynftp_server'
|
2
|
+
require_relative 'file_system_provider'
|
3
|
+
|
4
|
+
require 'timeout'
|
5
|
+
|
6
|
+
Thread.abort_on_exception = true
|
7
|
+
|
8
|
+
module TestFtpd
|
9
|
+
|
10
|
+
class Server < DynFTPServer
|
11
|
+
def initialize(config = {})
|
12
|
+
config.merge!(:root => FileSystemProvider.new(config[:root_dir]))
|
13
|
+
@ftp_thread = nil
|
14
|
+
super(config)
|
15
|
+
end
|
16
|
+
|
17
|
+
def running?
|
18
|
+
@ftp_thread && @ftp_thread.alive?
|
19
|
+
end
|
20
|
+
|
21
|
+
def start(timeout = 2)
|
22
|
+
Timeout.timeout(timeout) do
|
23
|
+
return if @ftp_thread
|
24
|
+
@ftp_thread = Thread.new { mainloop }
|
25
|
+
sleep 0.1 until running?
|
26
|
+
end
|
27
|
+
rescue TimeoutError
|
28
|
+
raise TimeoutError.new('TestFtpd::Server timeout before start succeeded.')
|
29
|
+
end
|
30
|
+
|
31
|
+
def shutdown(timeout = 2)
|
32
|
+
Timeout.timeout(timeout) do
|
33
|
+
@server.close unless @server.closed?
|
34
|
+
return if @ftp_thread.nil?
|
35
|
+
@ftp_thread.kill
|
36
|
+
sleep 0.1 while running?
|
37
|
+
@ftp_thread = nil
|
38
|
+
end
|
39
|
+
rescue TimeoutError
|
40
|
+
raise TimeoutError.new('TestFtpd::Server timeout before shutdown succeeded.')
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
data/lib/testftpd.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
a file in a sub folder
|
@@ -0,0 +1 @@
|
|
1
|
+
a file in a sub folder
|
@@ -0,0 +1 @@
|
|
1
|
+
welcome to my FTP server
|
@@ -0,0 +1 @@
|
|
1
|
+
this file will be deleted
|
@@ -0,0 +1 @@
|
|
1
|
+
this file will be renamed
|
@@ -0,0 +1 @@
|
|
1
|
+
test file
|
@@ -0,0 +1,174 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
require 'net/ftp'
|
4
|
+
require 'tmpdir'
|
5
|
+
|
6
|
+
require 'testftpd'
|
7
|
+
|
8
|
+
describe TestFtpd do
|
9
|
+
def copy_ftproot(target_path)
|
10
|
+
FileUtils.cp_r(File.join(APP_PATH, 'spec/fixtures/ftproot/.'), target_path)
|
11
|
+
end
|
12
|
+
|
13
|
+
let(:ftp_port) { 21212 }
|
14
|
+
let(:ftp_root) { Dir.mktmpdir('test_ftp_root') }
|
15
|
+
|
16
|
+
subject { TestFtpd::Server.new(port: ftp_port, root_dir: ftp_root) }
|
17
|
+
|
18
|
+
before do
|
19
|
+
subject.start
|
20
|
+
copy_ftproot(ftp_root)
|
21
|
+
end
|
22
|
+
|
23
|
+
after do
|
24
|
+
subject.shutdown
|
25
|
+
FileUtils.rm_rf(ftp_root)
|
26
|
+
end
|
27
|
+
|
28
|
+
context 'before authenticating' do
|
29
|
+
before :each do
|
30
|
+
@ftp = Net::FTP.new
|
31
|
+
@ftp.connect('127.0.0.1', ftp_port)
|
32
|
+
end
|
33
|
+
|
34
|
+
after :each do
|
35
|
+
@ftp.close
|
36
|
+
end
|
37
|
+
|
38
|
+
it 'sends unauthorised message' do
|
39
|
+
expect { @ftp.list }.to raise_error(Net::FTPPermError, /Not logged in/)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
context 'after authenticating' do
|
44
|
+
before :each do
|
45
|
+
@ftp = Net::FTP.new
|
46
|
+
@ftp.connect('127.0.0.1', ftp_port)
|
47
|
+
@ftp.login('username', 'password')
|
48
|
+
end
|
49
|
+
|
50
|
+
after :each do
|
51
|
+
@ftp.close
|
52
|
+
end
|
53
|
+
|
54
|
+
context 'when listing files' do
|
55
|
+
it 'can list remote files' do
|
56
|
+
files = @ftp.list
|
57
|
+
files.count.should eq(5)
|
58
|
+
files.any? { |file| file =~ /test_file/ }.should be_true
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'can list a specific file' do
|
62
|
+
files = @ftp.list('test_file')
|
63
|
+
files.count.should eq(1)
|
64
|
+
files[0].should =~ /test_file/
|
65
|
+
end
|
66
|
+
|
67
|
+
it 'can list a specific file in a subfolder' do
|
68
|
+
files = @ftp.list('subfolder/test_file1')
|
69
|
+
files.count.should eq(1)
|
70
|
+
files[0].should =~ /test_file1/
|
71
|
+
end
|
72
|
+
|
73
|
+
it 'can list a sub folder' do
|
74
|
+
files = @ftp.list('subfolder')
|
75
|
+
files.count.should eq(3)
|
76
|
+
files.any? { |file| file =~ /test_file1/ }.should be_true
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
it 'can query the current remote directory' do
|
81
|
+
@ftp.pwd.should eq('/')
|
82
|
+
end
|
83
|
+
|
84
|
+
it 'can nlst remote files' do
|
85
|
+
pending 'nlst command not implemented yet'
|
86
|
+
end
|
87
|
+
|
88
|
+
it 'can query modified time of remote file' do
|
89
|
+
modified_time = File.mtime(File.join(ftp_root, 'test_file')).strftime('%Y%m%d%H%M%S')
|
90
|
+
@ftp.mdtm('test_file').should eql(modified_time)
|
91
|
+
end
|
92
|
+
|
93
|
+
it 'can query size of remote file' do
|
94
|
+
size = File.size(File.join(ftp_root, 'test_file'))
|
95
|
+
@ftp.size('test_file').should eq(size)
|
96
|
+
end
|
97
|
+
|
98
|
+
context 'when downloading a file' do
|
99
|
+
it 'can use binary mode' do
|
100
|
+
filename = 'test_file'
|
101
|
+
local_filepath = File.join(Dir.tmpdir, filename)
|
102
|
+
@ftp.binary = true
|
103
|
+
@ftp.get('test_file', local_filepath)
|
104
|
+
File.exists?(local_filepath).should be_true
|
105
|
+
end
|
106
|
+
|
107
|
+
it 'can use text mode' do
|
108
|
+
filename = 'test_file'
|
109
|
+
local_filepath = File.join(Dir.tmpdir, filename)
|
110
|
+
@ftp.binary = false
|
111
|
+
@ftp.get('test_file', local_filepath)
|
112
|
+
File.exists?(local_filepath).should be_true
|
113
|
+
end
|
114
|
+
|
115
|
+
it 'can use passive mode' do
|
116
|
+
filename = 'test_file'
|
117
|
+
local_filepath = File.join(Dir.tmpdir, filename)
|
118
|
+
@ftp.passive = true
|
119
|
+
@ftp.get('test_file', local_filepath)
|
120
|
+
File.exists?(local_filepath).should be_true
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
context 'when changing folders' do
|
125
|
+
it 'can change into sub folders' do
|
126
|
+
@ftp.chdir('subfolder')
|
127
|
+
@ftp.pwd.should eq(File.join(ftp_root, 'subfolder'))
|
128
|
+
end
|
129
|
+
|
130
|
+
it 'can change into parent folder' do
|
131
|
+
@ftp.chdir('subfolder')
|
132
|
+
@ftp.chdir('..')
|
133
|
+
@ftp.pwd.should eq(ftp_root)
|
134
|
+
end
|
135
|
+
|
136
|
+
it 'does not allow folders outside the ftp root folder' do
|
137
|
+
@ftp.chdir('..')
|
138
|
+
@ftp.pwd.should eq('/')
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
it 'can create a remote folder' do
|
143
|
+
@ftp.mkdir('new_subfolder')
|
144
|
+
Dir.exists?(File.join(ftp_root, 'new_subfolder')).should be_true
|
145
|
+
end
|
146
|
+
|
147
|
+
it 'can upload a file' do
|
148
|
+
filename = 'test_file_to_upload'
|
149
|
+
local_filepath = File.join(APP_PATH, 'spec/fixtures', filename)
|
150
|
+
@ftp.put(local_filepath, filename)
|
151
|
+
File.exists?(File.join(ftp_root, filename)).should be_true
|
152
|
+
end
|
153
|
+
|
154
|
+
it 'can delete a remote file' do
|
155
|
+
@ftp.delete('test_file_to_delete')
|
156
|
+
File.exists?(File.join(ftp_root, 'test_file_to_delete')).should be_false
|
157
|
+
end
|
158
|
+
|
159
|
+
it 'can rename a remote file' do
|
160
|
+
@ftp.rename('test_file_to_rename', 'test_file_renamed')
|
161
|
+
File.exists?(File.join(ftp_root, 'test_file_renamed')).should be_true
|
162
|
+
end
|
163
|
+
|
164
|
+
it 'can delete a remote folder' do
|
165
|
+
@ftp.rmdir('subfolder_to_delete')
|
166
|
+
File.exists?(File.join(ftp_root, 'subfolder_to_delete')).should be_false
|
167
|
+
end
|
168
|
+
|
169
|
+
it 'responds correctly to unrecognized commands' do
|
170
|
+
expect { @ftp.sendcmd('asdf') }.to raise_error(Net::FTPPermError, /command unrecognized/)
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'bundler/setup'
|
3
|
+
require 'rspec'
|
4
|
+
|
5
|
+
APP_PATH = File.expand_path(File.join(File.dirname(__FILE__),'..'))
|
6
|
+
|
7
|
+
if ENV['COVERAGE']
|
8
|
+
require 'simplecov'
|
9
|
+
SimpleCov.root(APP_PATH)
|
10
|
+
end
|
11
|
+
|
12
|
+
RSpec.configure do |config|
|
13
|
+
config.before(:suite) do
|
14
|
+
|
15
|
+
end
|
16
|
+
|
17
|
+
config.before(:each) do
|
18
|
+
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
require 'testftpd'
|
4
|
+
|
5
|
+
describe TestFtpd::Server do
|
6
|
+
|
7
|
+
let(:ftp_port) { 21212 }
|
8
|
+
let(:ftp_root) { APP_PATH }
|
9
|
+
|
10
|
+
subject { TestFtpd::Server.new(port: ftp_port, root_dir: ftp_root) }
|
11
|
+
|
12
|
+
after :each do
|
13
|
+
subject.shutdown
|
14
|
+
end
|
15
|
+
|
16
|
+
describe '.start(timeout = 2)' do
|
17
|
+
it 'raises exception if ftp process does not start within timeout' do
|
18
|
+
Thread.stub(:new) { sleep 0.5 }
|
19
|
+
expect { subject.start(0.1) }.to raise_error(TimeoutError)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
describe '.start(timeout = 2)' do
|
24
|
+
it 'raises exception if ftp process does not shutdown within timeout' do
|
25
|
+
subject.start
|
26
|
+
expect { subject.shutdown(0.01) }.to raise_error(TimeoutError)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
2013-03-09 ftp-dyn-serv 0.1.3 (Christian Schulze)
|
2
|
+
Added rename file commands (rnfr/rnto)
|
3
|
+
Added remove directory command (rmd)
|
4
|
+
minor code improvements
|
5
|
+
|
6
|
+
2007-11-11 ftp-dyn-serv 0.1.2
|
7
|
+
Added ip masquerading feature.
|
8
|
+
Added settings for minimum and maximum passive port numbers.
|
9
|
+
Fixed issue with authentication.
|
10
|
+
|
11
|
+
2007-11-09 ftp-dyn-serv 0.1.1
|
12
|
+
Updated rdoc comments.
|
13
|
+
|
14
|
+
2007-11-09 ftp-dyn-serv 0.1.0
|
15
|
+
Initial release.
|
@@ -0,0 +1,22 @@
|
|
1
|
+
The MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2007 Rubtsov Vitaly
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all 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,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
22
|
+
|
@@ -0,0 +1,478 @@
|
|
1
|
+
# :title:Dynamic FTP server in pure Ruby (dyn-ftp-serv)
|
2
|
+
# Version:: 0.1.2
|
3
|
+
# Author:: Rubtsov Vitaly (vlrubtsov *at* gmail.com)
|
4
|
+
# License:: MIT license
|
5
|
+
# Website:: http://rubyforge.org/projects/dyn-ftp-serv/
|
6
|
+
#
|
7
|
+
# This ftp server implementation features an ability to host any content you want.
|
8
|
+
# You are not limited to hosting files and directories via FTP interface.
|
9
|
+
# With dyn-ftp-serv you are able to represent any hierarchy under the guise of
|
10
|
+
# standard files and directories. You will be able to download and upload files
|
11
|
+
# and browse dynamic directories.
|
12
|
+
# To work with dyn-ftp-serv you must have an object responding to special ftp messages
|
13
|
+
# that will represent the ftp content. You can create a new object or extend the
|
14
|
+
# existing one with special messages.
|
15
|
+
# There are two sets of messages to be handled: directory messages and file messages.
|
16
|
+
# Directory messages are:
|
17
|
+
# [+directory?+] must return true.
|
18
|
+
# [+ftp_name+] must return the name of a directory
|
19
|
+
# [+ftp_size+] must return size for directory
|
20
|
+
# [+ftp_date+] must return the date for a directory
|
21
|
+
# [+ftp_parent+] must return parent object or nil if root
|
22
|
+
# [+ftp_list+] must return an array of ftp objects
|
23
|
+
# [<tt>ftp_create(name, dir = false)</tt>]
|
24
|
+
# must return a new object created with the 'name' given.
|
25
|
+
# It can be file (dir=false) or a directory (dir=true). It can return nil if creating is
|
26
|
+
# forbidden.
|
27
|
+
# [+ftp_delete+] directory deletion request. must return true on success, and false on failure.
|
28
|
+
# File messages are:
|
29
|
+
# [+directory?+] must return false
|
30
|
+
# [+ftp_name+] must return the name of a file
|
31
|
+
# [+ftp_size+] must return filesize
|
32
|
+
# [+ftp_date+] must return filedate
|
33
|
+
# [<tt>ftp_retrieve(output)</tt>] streams file contents via output socket.
|
34
|
+
# [<tt>ftp_store(input)</tt>] writes file contents reading from a socket
|
35
|
+
# [+ftp_delete+] file deletion request. must return true on success, and false on failure.
|
36
|
+
#
|
37
|
+
# Please, see an example in 'examples' folder showing an implementation of standard file system
|
38
|
+
# ftp server.
|
39
|
+
|
40
|
+
require 'socket'
|
41
|
+
|
42
|
+
class DynFTPServer
|
43
|
+
|
44
|
+
attr_reader :config
|
45
|
+
|
46
|
+
# Class to instantiate if logger is not given.
|
47
|
+
class DummyLogger
|
48
|
+
def method_missing(method_name, *args, &block); end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Pass a hash containing options.
|
52
|
+
# [<tt>:host</tt>] Local bind address. Default is <em>'0.0.0.0'</em>.
|
53
|
+
# [<tt>:port</tt>] Port to listen. Default is <em>21</em>.
|
54
|
+
# [<tt>:masquerade_ip</tt>] IP masquerade for passive connections. Use this settings if you are behind a firewall and set it to the external ip address.
|
55
|
+
# [<tt>:pasv_min_port</tt>] Minimum port num for passive connections.
|
56
|
+
# [<tt>:pasv_max_port</tt>] Maximum port num for passive connections.
|
57
|
+
# [<tt>:root</tt>] Root ftp object.
|
58
|
+
# [<tt>:authentication</tt>] Function used to check users login information.
|
59
|
+
# [<tt>:logger</tt>] Logger object.
|
60
|
+
def initialize(config)
|
61
|
+
@config = defaults.merge(config)
|
62
|
+
raise(ArgumentError, 'Root object must not be null.') unless @config[:root]
|
63
|
+
@server = TCPServer.new(@config[:host], @config[:port])
|
64
|
+
end
|
65
|
+
|
66
|
+
def defaults
|
67
|
+
{
|
68
|
+
host: '',
|
69
|
+
port: 21,
|
70
|
+
masquerade_ip: nil,
|
71
|
+
pasv_min_port: 1024,
|
72
|
+
pasv_max_port: 65535,
|
73
|
+
root: nil,
|
74
|
+
authentication: ->(user, pass) { return true },
|
75
|
+
logger: nil
|
76
|
+
}
|
77
|
+
end
|
78
|
+
|
79
|
+
def mainloop
|
80
|
+
threads = []
|
81
|
+
log.debug 'Waiting for connection'
|
82
|
+
while (session = @server.accept)
|
83
|
+
log.debug "Accepted connection from #{session.addr.join(', ')}"
|
84
|
+
threads << Thread.new(session) do |s|
|
85
|
+
thread[:socket] = s
|
86
|
+
client_loop
|
87
|
+
end
|
88
|
+
end
|
89
|
+
threads.each {|t| t.join }
|
90
|
+
end
|
91
|
+
|
92
|
+
private
|
93
|
+
|
94
|
+
def log
|
95
|
+
return config[:logger] if config[:logger]
|
96
|
+
DummyLogger.new
|
97
|
+
end
|
98
|
+
|
99
|
+
def not_implemented
|
100
|
+
status(500)
|
101
|
+
end
|
102
|
+
|
103
|
+
def not_authorized
|
104
|
+
status(530)
|
105
|
+
end
|
106
|
+
|
107
|
+
def status(code, description = nil)
|
108
|
+
unless description.nil?
|
109
|
+
message = "#{code.to_s} #{description}"
|
110
|
+
log.debug "Response: #{message}"
|
111
|
+
thread[:socket].puts "#{message}\r\n"
|
112
|
+
return
|
113
|
+
end
|
114
|
+
case code.to_i
|
115
|
+
when 125
|
116
|
+
status(code, 'Data connection already open; transfer starting.')
|
117
|
+
when 150
|
118
|
+
status(code, 'File status okay; about to open data connection.')
|
119
|
+
when 200
|
120
|
+
status(code, 'Command okey.')
|
121
|
+
when 226
|
122
|
+
status(code, 'Closing data connection.')
|
123
|
+
when 230
|
124
|
+
status(code, 'User logged in, proceed.')
|
125
|
+
when 250
|
126
|
+
status(code, 'Requested file action okay, completed.')
|
127
|
+
when 331
|
128
|
+
status(code, 'User name okay, need password.')
|
129
|
+
when 350
|
130
|
+
status(code, 'RNFR completed, continue with RNTO.')
|
131
|
+
when 425
|
132
|
+
status(code, "Can't open data connection.")
|
133
|
+
when 500
|
134
|
+
status(code, 'Syntax error, command unrecognized.')
|
135
|
+
when 502
|
136
|
+
status(code, 'Command not implemented.')
|
137
|
+
when 530
|
138
|
+
status(code, 'Not logged in.')
|
139
|
+
when 550
|
140
|
+
status(code, 'Requested action not taken.')
|
141
|
+
else
|
142
|
+
status(code, '')
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
def data_connection(&block)
|
147
|
+
client_socket = nil
|
148
|
+
if thread[:passive]
|
149
|
+
unless IO.select([thread[:data_socket]], nil, nil, 60000)
|
150
|
+
status(425)
|
151
|
+
return false
|
152
|
+
end
|
153
|
+
client_socket = thread[:data_socket].accept
|
154
|
+
status(150)
|
155
|
+
else
|
156
|
+
client_socket = thread[:data_socket]
|
157
|
+
status(125)
|
158
|
+
end
|
159
|
+
yield(client_socket)
|
160
|
+
return true
|
161
|
+
ensure
|
162
|
+
client_socket.close if client_socket && thread[:passive]
|
163
|
+
client_socket = nil
|
164
|
+
end
|
165
|
+
|
166
|
+
def passive_server
|
167
|
+
server = nil
|
168
|
+
port = config[:pasv_min_port]
|
169
|
+
while server.nil? && (port <= config[:pasv_max_port])
|
170
|
+
begin
|
171
|
+
server = TCPServer.new(config[:host], port)
|
172
|
+
rescue Errno::EADDRINUSE
|
173
|
+
log.error "#{port} is already in use. Trying next port."
|
174
|
+
end
|
175
|
+
port += 1
|
176
|
+
end
|
177
|
+
server
|
178
|
+
end
|
179
|
+
|
180
|
+
def open_object(path)
|
181
|
+
if (path[0,1] == '/') || (path.is_a?(Array) && (path[0] == ''))
|
182
|
+
dir = config[:root]
|
183
|
+
else
|
184
|
+
dir = thread[:cwd]
|
185
|
+
end
|
186
|
+
path = path.split('/') unless path.is_a?(Array)
|
187
|
+
return dir if path.empty?
|
188
|
+
last_element = path.pop
|
189
|
+
path.each do |p|
|
190
|
+
unless p == ''
|
191
|
+
dir = dir.ftp_list.detect {|d| (d.ftp_name.casecmp(p) == 0) && (d.directory?) }
|
192
|
+
return nil unless dir
|
193
|
+
end
|
194
|
+
end
|
195
|
+
dir.ftp_list.detect {|d| (d.ftp_name.casecmp(last_element) == 0) } unless last_element == ''
|
196
|
+
end
|
197
|
+
|
198
|
+
def open_path(path)
|
199
|
+
result = open_object(path)
|
200
|
+
result = nil if result && !result.directory?
|
201
|
+
result
|
202
|
+
end
|
203
|
+
|
204
|
+
def open_file(path)
|
205
|
+
result = open_object(path)
|
206
|
+
result = nil if result && result.directory?
|
207
|
+
result
|
208
|
+
end
|
209
|
+
|
210
|
+
def get_path(object)
|
211
|
+
return '/' unless object
|
212
|
+
return '/' if object == config[:root]
|
213
|
+
result = ''
|
214
|
+
while object do
|
215
|
+
result = '/' + object.ftp_name + result
|
216
|
+
object = object.ftp_parent
|
217
|
+
end
|
218
|
+
result
|
219
|
+
end
|
220
|
+
|
221
|
+
def get_quoted_path(object)
|
222
|
+
get_path(object).gsub('"', '""')
|
223
|
+
end
|
224
|
+
|
225
|
+
def thread
|
226
|
+
Thread.current
|
227
|
+
end
|
228
|
+
|
229
|
+
# Commands
|
230
|
+
|
231
|
+
def cmd_cdup(params)
|
232
|
+
thread[:cwd] = thread[:cwd].ftp_parent(config[:root])
|
233
|
+
thread[:cwd] = config[:root] unless thread[:cwd]
|
234
|
+
status(250, 'Directory successfully changed.')
|
235
|
+
end
|
236
|
+
|
237
|
+
def cmd_cwd(path)
|
238
|
+
if path == '.'
|
239
|
+
status(250, 'Directory successfully changed.')
|
240
|
+
elsif (newpath = open_path(path))
|
241
|
+
thread[:cwd] = newpath
|
242
|
+
status(250, 'Directory successfully changed.')
|
243
|
+
else
|
244
|
+
status(550, 'Failed to change directory.')
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
def cmd_dele(path)
|
249
|
+
if (file = open_file(path)) && file.ftp_delete
|
250
|
+
status 250
|
251
|
+
else
|
252
|
+
status(550, 'Delete operation failed.')
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
# def cmd_feat(params)
|
257
|
+
# thread[:socket].puts "211-Features\r\n"
|
258
|
+
# thread[:socket].puts " UTF8\r\n"
|
259
|
+
# thread[:socket].puts "211 end\r\n"
|
260
|
+
# end
|
261
|
+
|
262
|
+
def cmd_list(file_spec)
|
263
|
+
data_connection do |data_socket|
|
264
|
+
list = thread[:cwd].ftp_list(file_spec)
|
265
|
+
list.each {|file| data_socket.puts(((file.directory?) ? 'd': '-') + 'rw-rw-rw- 1 ftp ftp ' + file.ftp_size.to_s + ' ' + file.ftp_date.strftime('%b %d %H:%M') + ' ' + file.ftp_name + "\r\n") }
|
266
|
+
end
|
267
|
+
thread[:data_socket].close if thread[:data_socket]
|
268
|
+
thread[:data_socket] = nil
|
269
|
+
|
270
|
+
status(226, 'Transfer complete')
|
271
|
+
end
|
272
|
+
|
273
|
+
def cmd_mdtm(path)
|
274
|
+
file = open_file(path)
|
275
|
+
if file
|
276
|
+
status(213, file.ftp_date.strftime('%Y%m%d%H%M%S'))
|
277
|
+
else
|
278
|
+
status(550, 'Could not get modification time.')
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
def cmd_mkd(path)
|
283
|
+
dir = open_object(path)
|
284
|
+
if dir
|
285
|
+
status(521, 'Directory already exists')
|
286
|
+
return
|
287
|
+
end
|
288
|
+
splitted_path = path.split('/')
|
289
|
+
mkdir = splitted_path.pop
|
290
|
+
dir = open_path(splitted_path)
|
291
|
+
if dir and (newdir = dir.ftp_create(mkdir, true))
|
292
|
+
status(257, '"' + get_quoted_path(newdir) + '" directory created.')
|
293
|
+
else
|
294
|
+
status(550)
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
def cmd_pass(pass)
|
299
|
+
thread[:pass] = pass
|
300
|
+
if config[:authentication].call(thread[:user], thread[:pass])
|
301
|
+
thread[:authenticated] = true
|
302
|
+
status(230)
|
303
|
+
else
|
304
|
+
thread[:authenticated] = false
|
305
|
+
not_authorized
|
306
|
+
end
|
307
|
+
end
|
308
|
+
|
309
|
+
def cmd_pasv(params)
|
310
|
+
if thread[:data_socket]
|
311
|
+
thread[:data_socket].close
|
312
|
+
thread[:data_socket] = nil
|
313
|
+
end
|
314
|
+
thread[:data_socket] = passive_server
|
315
|
+
return status(425) if thread[:data_socket].nil?
|
316
|
+
thread[:passive] = true
|
317
|
+
port = thread[:data_socket].addr[1]
|
318
|
+
port_lo = port & '0x00FF'.hex
|
319
|
+
port_hi = port >> 8
|
320
|
+
ip = thread[:data_socket].addr[3]
|
321
|
+
ip = config[:masquerade_ip] if config[:masquerade_ip]
|
322
|
+
ip = ip.split('.')
|
323
|
+
status(227, "Entering Passive Mode (#{ip[0]},#{ip[1]},#{ip[2]},#{ip[3]},#{port_hi},#{port_lo})")
|
324
|
+
end
|
325
|
+
|
326
|
+
def cmd_port(ip_port)
|
327
|
+
s = ip_port.split(',')
|
328
|
+
port = s[4].to_i * 256 + s[5].to_i
|
329
|
+
host = s[0..3].join('.')
|
330
|
+
if thread[:data_socket]
|
331
|
+
thread[:data_socket].close
|
332
|
+
thread[:data_socket] = nil
|
333
|
+
end
|
334
|
+
thread[:data_socket] = TCPSocket.new(host, port)
|
335
|
+
thread[:passive] = false
|
336
|
+
status(200, "Passive connection established (#{port})")
|
337
|
+
end
|
338
|
+
|
339
|
+
def cmd_pwd(params)
|
340
|
+
status(257, "\"#{get_quoted_path(thread[:cwd])}\" is the current directory")
|
341
|
+
end
|
342
|
+
|
343
|
+
def cmd_rmd(path)
|
344
|
+
dir = open_path(path)
|
345
|
+
if dir && dir.ftp_delete(true)
|
346
|
+
status(250)
|
347
|
+
else
|
348
|
+
status(550, 'Remove directory operation failed.')
|
349
|
+
end
|
350
|
+
end
|
351
|
+
|
352
|
+
def cmd_quit(params)
|
353
|
+
status(200)
|
354
|
+
thread[:socket].close
|
355
|
+
thread[:socket] = nil
|
356
|
+
end
|
357
|
+
|
358
|
+
def cmd_retr(path)
|
359
|
+
file = open_file(path)
|
360
|
+
if file
|
361
|
+
data_connection do |data_socket|
|
362
|
+
if file.ftp_retrieve(data_socket)
|
363
|
+
status(226, 'Transfer complete')
|
364
|
+
else
|
365
|
+
status(550, 'Failed to open file.')
|
366
|
+
end
|
367
|
+
end
|
368
|
+
else
|
369
|
+
status(550, 'Failed to open file.')
|
370
|
+
end
|
371
|
+
|
372
|
+
thread[:data_socket].close if thread[:data_socket]
|
373
|
+
thread[:data_socket] = nil
|
374
|
+
end
|
375
|
+
|
376
|
+
def cmd_size(path)
|
377
|
+
file = open_file(path)
|
378
|
+
if file
|
379
|
+
status(213, file.ftp_size.to_s)
|
380
|
+
else
|
381
|
+
status(550, 'Could not get file size.')
|
382
|
+
end
|
383
|
+
end
|
384
|
+
|
385
|
+
def cmd_stor(path)
|
386
|
+
file = open_file(path)
|
387
|
+
if file
|
388
|
+
status(553, 'Could not create file.')
|
389
|
+
return
|
390
|
+
end
|
391
|
+
unless file
|
392
|
+
splitted_path = path.split('/')
|
393
|
+
filename = splitted_path.pop
|
394
|
+
dir = open_path(splitted_path)
|
395
|
+
file = dir.ftp_create(filename) if dir
|
396
|
+
end
|
397
|
+
if file
|
398
|
+
data_connection do |data_socket|
|
399
|
+
file.ftp_store(data_socket)
|
400
|
+
end
|
401
|
+
status(226, 'Transfer complete')
|
402
|
+
else
|
403
|
+
status(550, 'Failed to open file.')
|
404
|
+
end
|
405
|
+
|
406
|
+
thread[:data_socket].close if thread[:data_socket]
|
407
|
+
thread[:data_socket] = nil
|
408
|
+
end
|
409
|
+
|
410
|
+
def cmd_rnfr(from_name)
|
411
|
+
file = open_file(from_name)
|
412
|
+
if file
|
413
|
+
@rnfr = file
|
414
|
+
status(350)
|
415
|
+
else
|
416
|
+
status(550, 'Rename from operation failed.')
|
417
|
+
end
|
418
|
+
end
|
419
|
+
|
420
|
+
def cmd_rnto(to_name)
|
421
|
+
if @rnfr
|
422
|
+
@rnfr.ftp_rename(to_name)
|
423
|
+
status(250)
|
424
|
+
else
|
425
|
+
status(550, 'Rename to operation failed.')
|
426
|
+
end
|
427
|
+
end
|
428
|
+
|
429
|
+
def cmd_syst(params)
|
430
|
+
status(215, 'UNIX')
|
431
|
+
end
|
432
|
+
|
433
|
+
def cmd_type(type)
|
434
|
+
status(200, 'Type set.')
|
435
|
+
end
|
436
|
+
|
437
|
+
def cmd_user(user)
|
438
|
+
thread[:user] = user
|
439
|
+
status(331)
|
440
|
+
end
|
441
|
+
|
442
|
+
def welcome
|
443
|
+
thread[:authenticated] = false
|
444
|
+
thread[:cwd] = config[:root]
|
445
|
+
status(220, 'Microsoft FTP Server ready')
|
446
|
+
end
|
447
|
+
|
448
|
+
def client_loop
|
449
|
+
welcome
|
450
|
+
while thread[:socket]
|
451
|
+
s = thread[:socket].gets
|
452
|
+
break unless s
|
453
|
+
s.chomp!
|
454
|
+
log.debug "Request: #{s}"
|
455
|
+
params = s.split(' ', 2)
|
456
|
+
command = params.first
|
457
|
+
command.downcase! if command
|
458
|
+
message = "cmd_#{command.to_s}"
|
459
|
+
if self.respond_to?(message, true)
|
460
|
+
if %w[cmd_user cmd_pass].include?(message) || thread[:authenticated]
|
461
|
+
self.send(message, params[1])
|
462
|
+
else
|
463
|
+
not_authorized
|
464
|
+
end
|
465
|
+
else
|
466
|
+
not_implemented
|
467
|
+
end
|
468
|
+
end
|
469
|
+
#rescue
|
470
|
+
# log.error $!
|
471
|
+
ensure
|
472
|
+
thread[:socket].close if thread[:socket] && !thread[:socket].closed?
|
473
|
+
thread[:socket] = nil
|
474
|
+
thread[:data_socket].close if thread[:data_socket] && !thread[:data_socket].closed?
|
475
|
+
thread[:data_socket] = nil
|
476
|
+
end
|
477
|
+
|
478
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
require '../dynftp_server'
|
2
|
+
require 'logger'
|
3
|
+
|
4
|
+
Thread.abort_on_exception = true
|
5
|
+
|
6
|
+
class FSProvider
|
7
|
+
attr_reader :ftp_name, :ftp_size, :ftp_dir, :ftp_date
|
8
|
+
|
9
|
+
def ftp_parent
|
10
|
+
path = @path.split('/')
|
11
|
+
return nil unless path.pop
|
12
|
+
return nil if path.size <= 1
|
13
|
+
return FSProvider.new(path.join('/'))
|
14
|
+
end
|
15
|
+
|
16
|
+
def ftp_list
|
17
|
+
output = Array.new
|
18
|
+
Dir.entries(@path).sort.each do |file|
|
19
|
+
output << FSProvider.new(@path + (@path == '/'? '': '/') + file)
|
20
|
+
end
|
21
|
+
return output
|
22
|
+
end
|
23
|
+
|
24
|
+
def ftp_create(name, dir = false)
|
25
|
+
if dir
|
26
|
+
begin
|
27
|
+
Dir.mkdir(@path + '/' + name)
|
28
|
+
return FSProvider.new(@path + '/' + name)
|
29
|
+
rescue
|
30
|
+
return false
|
31
|
+
end
|
32
|
+
else
|
33
|
+
FSProvider.new(@path + '/' + name)
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|
37
|
+
|
38
|
+
def ftp_retrieve(output)
|
39
|
+
output << File.new(@path, 'r').read
|
40
|
+
end
|
41
|
+
|
42
|
+
def ftp_store(input)
|
43
|
+
return false unless File.open(@path, 'w') do |f|
|
44
|
+
f.write input.read
|
45
|
+
end
|
46
|
+
@ftp_size = File.size?(@path)
|
47
|
+
@ftp_date = File.mtime(@path) if File.exists?(@path)
|
48
|
+
end
|
49
|
+
|
50
|
+
def ftp_delete()
|
51
|
+
return false
|
52
|
+
end
|
53
|
+
|
54
|
+
def initialize(path)
|
55
|
+
@path = path
|
56
|
+
@ftp_name = path.split('/').last
|
57
|
+
@ftp_name = '/' unless @ftp_name
|
58
|
+
@ftp_dir = File.directory?(path)
|
59
|
+
@ftp_size = File.size?(path)
|
60
|
+
@ftp_size = 0 unless @ftp_size
|
61
|
+
@ftp_date = Time.now
|
62
|
+
@ftp_date = File.mtime(path) if File.exists?(path)
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
66
|
+
|
67
|
+
|
68
|
+
log = Logger.new(STDOUT)
|
69
|
+
log.datetime_format = "%H:%M:%S"
|
70
|
+
log.progname = "ftpserv.rb"
|
71
|
+
|
72
|
+
root = FSProvider.new('/')
|
73
|
+
auth =
|
74
|
+
lambda do |user,pass|
|
75
|
+
return false unless user.casecmp('anonymous') == 0
|
76
|
+
return true
|
77
|
+
end
|
78
|
+
s = DynFTPServer.new(:port => 21, :root => root, :authentication => auth, :logger => log)
|
79
|
+
s.mainloop
|
metadata
ADDED
@@ -0,0 +1,138 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: testftpd
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Christian Schulze
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-03-13 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: rake
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: 10.0.3
|
22
|
+
type: :development
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ~>
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: 10.0.3
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: rspec
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ~>
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '2.12'
|
38
|
+
type: :development
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ~>
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '2.12'
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: simplecov
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - ~>
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: 0.7.1
|
54
|
+
type: :development
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ~>
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 0.7.1
|
62
|
+
- !ruby/object:Gem::Dependency
|
63
|
+
name: simplecov-rcov
|
64
|
+
requirement: !ruby/object:Gem::Requirement
|
65
|
+
none: false
|
66
|
+
requirements:
|
67
|
+
- - ~>
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: 0.2.3
|
70
|
+
type: :development
|
71
|
+
prerelease: false
|
72
|
+
version_requirements: !ruby/object:Gem::Requirement
|
73
|
+
none: false
|
74
|
+
requirements:
|
75
|
+
- - ~>
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: 0.2.3
|
78
|
+
description: Simple FTP server written in pure Ruby, allowing integration testing
|
79
|
+
of FTP client code without mocks and stubs
|
80
|
+
email:
|
81
|
+
- christian.schulze@gmail.com
|
82
|
+
executables:
|
83
|
+
- testftpd
|
84
|
+
extensions: []
|
85
|
+
extra_rdoc_files: []
|
86
|
+
files:
|
87
|
+
- lib/testftpd/file_system_provider.rb
|
88
|
+
- lib/testftpd/server.rb
|
89
|
+
- lib/testftpd/version.rb
|
90
|
+
- lib/testftpd.rb
|
91
|
+
- vendor/dyn-ftp-serv/CHANGELOG
|
92
|
+
- vendor/dyn-ftp-serv/dynftp_server.rb
|
93
|
+
- vendor/dyn-ftp-serv/examples/ftpserv.rb
|
94
|
+
- vendor/dyn-ftp-serv/LICENSE
|
95
|
+
- bin/testftpd
|
96
|
+
- spec/fixtures/ftproot/subfolder/test_file1
|
97
|
+
- spec/fixtures/ftproot/subfolder/test_file2
|
98
|
+
- spec/fixtures/ftproot/test_file
|
99
|
+
- spec/fixtures/ftproot/test_file_to_delete
|
100
|
+
- spec/fixtures/ftproot/test_file_to_rename
|
101
|
+
- spec/fixtures/test_file_to_upload
|
102
|
+
- spec/integration/test_ftp_spec.rb
|
103
|
+
- spec/spec_helper.rb
|
104
|
+
- spec/unit/testftpd/server_spec.rb
|
105
|
+
homepage: https://github.com/christian-schulze/testftpd
|
106
|
+
licenses: []
|
107
|
+
post_install_message:
|
108
|
+
rdoc_options: []
|
109
|
+
require_paths:
|
110
|
+
- lib
|
111
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
112
|
+
none: false
|
113
|
+
requirements:
|
114
|
+
- - ~>
|
115
|
+
- !ruby/object:Gem::Version
|
116
|
+
version: 1.9.2
|
117
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
118
|
+
none: false
|
119
|
+
requirements:
|
120
|
+
- - ~>
|
121
|
+
- !ruby/object:Gem::Version
|
122
|
+
version: 1.8.25
|
123
|
+
requirements: []
|
124
|
+
rubyforge_project: testftpd
|
125
|
+
rubygems_version: 1.8.25
|
126
|
+
signing_key:
|
127
|
+
specification_version: 3
|
128
|
+
summary: Simple FTP server written in pure Ruby
|
129
|
+
test_files:
|
130
|
+
- spec/fixtures/ftproot/subfolder/test_file1
|
131
|
+
- spec/fixtures/ftproot/subfolder/test_file2
|
132
|
+
- spec/fixtures/ftproot/test_file
|
133
|
+
- spec/fixtures/ftproot/test_file_to_delete
|
134
|
+
- spec/fixtures/ftproot/test_file_to_rename
|
135
|
+
- spec/fixtures/test_file_to_upload
|
136
|
+
- spec/integration/test_ftp_spec.rb
|
137
|
+
- spec/spec_helper.rb
|
138
|
+
- spec/unit/testftpd/server_spec.rb
|