iostreams 1.11.0 → 2.1.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.
- checksums.yaml +4 -4
- data/README.md +14 -13
- data/Rakefile +52 -0
- data/docs/CLAUDE.md +9 -0
- data/docs/config.md +157 -0
- data/docs/copy_files.md +75 -0
- data/docs/extensions.md +111 -0
- data/docs/formats.md +188 -0
- data/docs/index.md +388 -0
- data/docs/path.md +652 -0
- data/docs/pgp.md +436 -0
- data/docs/streams.md +337 -0
- data/docs/tutorial.md +483 -0
- data/docs/upgrading.md +217 -0
- data/lib/io_streams/builder.rb +71 -11
- data/lib/io_streams/bzip2/reader.rb +25 -2
- data/lib/io_streams/bzip2/writer.rb +26 -2
- data/lib/io_streams/encode/reader.rb +6 -2
- data/lib/io_streams/encode/writer.rb +9 -5
- data/lib/io_streams/errors.rb +4 -0
- data/lib/io_streams/gzip/reader.rb +5 -1
- data/lib/io_streams/gzip/writer.rb +11 -2
- data/lib/io_streams/io_streams.rb +156 -20
- data/lib/io_streams/line/reader.rb +9 -4
- data/lib/io_streams/line/writer.rb +1 -1
- data/lib/io_streams/path.rb +117 -8
- data/lib/io_streams/paths/file.rb +57 -11
- data/lib/io_streams/paths/http.rb +123 -9
- data/lib/io_streams/paths/matcher.rb +3 -3
- data/lib/io_streams/paths/s3.rb +69 -18
- data/lib/io_streams/paths/sftp/net_ssh.rb +104 -0
- data/lib/io_streams/paths/sftp.rb +103 -64
- data/lib/io_streams/pgp/reader.rb +63 -10
- data/lib/io_streams/pgp/writer.rb +111 -30
- data/lib/io_streams/pgp.rb +256 -71
- data/lib/io_streams/reader.rb +14 -5
- data/lib/io_streams/record/reader.rb +75 -6
- data/lib/io_streams/record/writer.rb +3 -4
- data/lib/io_streams/row/reader.rb +1 -1
- data/lib/io_streams/row/writer.rb +1 -1
- data/lib/io_streams/stream.rb +48 -37
- data/lib/io_streams/symmetric_encryption/reader.rb +6 -2
- data/lib/io_streams/symmetric_encryption/writer.rb +8 -4
- data/lib/io_streams/tabular/header.rb +49 -10
- data/lib/io_streams/tabular/parser/array.rb +0 -10
- data/lib/io_streams/tabular/parser/base.rb +10 -0
- data/lib/io_streams/tabular/parser/csv.rb +9 -36
- data/lib/io_streams/tabular/parser/fixed.rb +8 -6
- data/lib/io_streams/tabular/parser/psv.rb +6 -14
- data/lib/io_streams/tabular.rb +5 -10
- data/lib/io_streams/utils.rb +34 -2
- data/lib/io_streams/version.rb +1 -1
- data/lib/io_streams/writer.rb +16 -7
- data/lib/io_streams/xlsx/reader.rb +6 -2
- data/lib/io_streams/zip/reader.rb +4 -0
- data/lib/io_streams/zip/writer.rb +26 -10
- data/lib/iostreams.rb +0 -1
- metadata +46 -112
- data/lib/io_streams/deprecated.rb +0 -216
- data/lib/io_streams/tabular/utility/csv_row.rb +0 -105
- data/test/builder_test.rb +0 -311
- data/test/bzip2_reader_test.rb +0 -27
- data/test/bzip2_writer_test.rb +0 -56
- data/test/deprecated_test.rb +0 -121
- data/test/encode_reader_test.rb +0 -51
- data/test/encode_writer_test.rb +0 -90
- data/test/files/embedded_lines_test.csv +0 -7
- data/test/files/multiple_files.zip +0 -0
- data/test/files/spreadsheet.xlsx +0 -0
- data/test/files/test.csv +0 -4
- data/test/files/test.json +0 -3
- data/test/files/test.psv +0 -4
- data/test/files/text file.txt +0 -3
- data/test/files/text.txt +0 -3
- data/test/files/text.txt.bz2 +0 -0
- data/test/files/text.txt.gz +0 -0
- data/test/files/text.txt.gz.zip +0 -0
- data/test/files/text.zip +0 -0
- data/test/files/text.zip.gz +0 -0
- data/test/files/unclosed_quote_large_test.csv +0 -1658
- data/test/files/unclosed_quote_test.csv +0 -4
- data/test/files/unclosed_quote_test2.csv +0 -3
- data/test/gzip_reader_test.rb +0 -27
- data/test/gzip_writer_test.rb +0 -52
- data/test/io_streams_test.rb +0 -132
- data/test/line_reader_test.rb +0 -325
- data/test/line_writer_test.rb +0 -59
- data/test/minimal_file_reader.rb +0 -25
- data/test/path_test.rb +0 -55
- data/test/paths/file_test.rb +0 -213
- data/test/paths/http_test.rb +0 -34
- data/test/paths/matcher_test.rb +0 -120
- data/test/paths/s3_test.rb +0 -220
- data/test/paths/sftp_test.rb +0 -106
- data/test/pgp_reader_test.rb +0 -46
- data/test/pgp_test.rb +0 -267
- data/test/pgp_writer_test.rb +0 -130
- data/test/record_reader_test.rb +0 -60
- data/test/record_writer_test.rb +0 -82
- data/test/row_reader_test.rb +0 -35
- data/test/row_writer_test.rb +0 -56
- data/test/stream_test.rb +0 -577
- data/test/tabular_test.rb +0 -338
- data/test/test_helper.rb +0 -40
- data/test/utils_test.rb +0 -20
- data/test/xlsx_reader_test.rb +0 -37
- data/test/zip_reader_test.rb +0 -53
- data/test/zip_writer_test.rb +0 -48
data/lib/io_streams/paths/s3.rb
CHANGED
|
@@ -21,6 +21,15 @@ module IOStreams
|
|
|
21
21
|
# s3://my-bucket-name/file_name.txt
|
|
22
22
|
# s3://my-bucket-name/some_path/file_name.csv
|
|
23
23
|
#
|
|
24
|
+
# Any query string in the url is added to the S3 request parameters, for example:
|
|
25
|
+
# s3://my-bucket-name/file_name.csv?acl=bucket-owner-full-control
|
|
26
|
+
#
|
|
27
|
+
# SECURITY WARNING:
|
|
28
|
+
# Do not interpolate untrusted file names into the url, since a name such as
|
|
29
|
+
# `file.csv?acl=public-read` would set request parameters.
|
|
30
|
+
# Instead join untrusted names onto the path, which does not parse them as a query:
|
|
31
|
+
# IOStreams.path("s3://my-bucket-name/uploads").join(untrusted_name)
|
|
32
|
+
#
|
|
24
33
|
# access_key_id: [String]
|
|
25
34
|
# AWS Access Key Id to use to access this bucket.
|
|
26
35
|
#
|
|
@@ -37,10 +46,10 @@ module IOStreams
|
|
|
37
46
|
#
|
|
38
47
|
# Example:
|
|
39
48
|
# client = Aws::S3::Client.new(endpoint: "https://s3.test.com")
|
|
40
|
-
# IOStreams::Paths::S3.new(client: client)
|
|
49
|
+
# IOStreams::Paths::S3.new("s3://bucket/path/file_name.txt", client: client)
|
|
41
50
|
#
|
|
42
51
|
# Example:
|
|
43
|
-
# IOStreams::Paths::S3.new(client: { endpoint: "https://s3.test.com" })
|
|
52
|
+
# IOStreams::Paths::S3.new("s3://bucket/path/file_name.txt", client: { endpoint: "https://s3.test.com" })
|
|
44
53
|
#
|
|
45
54
|
# Writer specific options:
|
|
46
55
|
#
|
|
@@ -185,6 +194,7 @@ module IOStreams
|
|
|
185
194
|
end
|
|
186
195
|
|
|
187
196
|
def delete
|
|
197
|
+
authorize!
|
|
188
198
|
client.delete_object(bucket: bucket_name, key: path)
|
|
189
199
|
self
|
|
190
200
|
rescue Aws::S3::Errors::NotFound
|
|
@@ -192,6 +202,7 @@ module IOStreams
|
|
|
192
202
|
end
|
|
193
203
|
|
|
194
204
|
def exist?
|
|
205
|
+
authorize!
|
|
195
206
|
client.head_object(bucket: bucket_name, key: path)
|
|
196
207
|
true
|
|
197
208
|
rescue Aws::S3::Errors::NotFound
|
|
@@ -211,11 +222,13 @@ module IOStreams
|
|
|
211
222
|
|
|
212
223
|
# Make S3 perform direct copies within S3 itself.
|
|
213
224
|
def copy_to(target_path, convert: true, **args)
|
|
214
|
-
return super
|
|
225
|
+
return super if convert || (size.to_i >= S3_COPY_OBJECT_SIZE_LIMIT)
|
|
215
226
|
|
|
216
227
|
target = IOStreams.new(target_path)
|
|
217
228
|
return super(target, convert: convert, **args) unless target.is_a?(self.class)
|
|
218
229
|
|
|
230
|
+
authorize!
|
|
231
|
+
target.authorize!
|
|
219
232
|
source_name = ::File.join(bucket_name, path)
|
|
220
233
|
client.copy_object(options.merge(bucket: target.bucket_name, key: target.path, copy_source: source_name))
|
|
221
234
|
target
|
|
@@ -230,6 +243,8 @@ module IOStreams
|
|
|
230
243
|
return super(source, convert: convert, **args)
|
|
231
244
|
end
|
|
232
245
|
|
|
246
|
+
authorize!
|
|
247
|
+
source.authorize!
|
|
233
248
|
source_name = ::File.join(source.bucket_name, source.path)
|
|
234
249
|
client.copy_object(options.merge(bucket: bucket_name, key: path, copy_source: source_name))
|
|
235
250
|
end
|
|
@@ -244,6 +259,7 @@ module IOStreams
|
|
|
244
259
|
end
|
|
245
260
|
|
|
246
261
|
def size
|
|
262
|
+
authorize!
|
|
247
263
|
client.head_object(bucket: bucket_name, key: path).content_length
|
|
248
264
|
rescue Aws::S3::Errors::NotFound
|
|
249
265
|
nil
|
|
@@ -254,7 +270,7 @@ module IOStreams
|
|
|
254
270
|
# Read from AWS S3 file.
|
|
255
271
|
def stream_reader(&block)
|
|
256
272
|
# Since S3 download only supports a push stream, write it to a tempfile first.
|
|
257
|
-
Utils.
|
|
273
|
+
Utils.private_temp_file("iostreams_s3") do |file_name|
|
|
258
274
|
read_file(file_name)
|
|
259
275
|
|
|
260
276
|
::File.open(file_name, "rb") { |io| builder.reader(io, &block) }
|
|
@@ -263,6 +279,7 @@ module IOStreams
|
|
|
263
279
|
|
|
264
280
|
# Shortcut method if caller has a filename already with no other streams applied:
|
|
265
281
|
def read_file(file_name)
|
|
282
|
+
authorize!
|
|
266
283
|
::File.open(file_name, "wb") do |file|
|
|
267
284
|
client.get_object(options.merge(response_target: file, bucket: bucket_name, key: path))
|
|
268
285
|
end
|
|
@@ -277,7 +294,7 @@ module IOStreams
|
|
|
277
294
|
# aborted.
|
|
278
295
|
def stream_writer(&block)
|
|
279
296
|
# Since S3 upload only supports a pull stream, write it to a tempfile first.
|
|
280
|
-
Utils.
|
|
297
|
+
Utils.private_temp_file("iostreams_s3") do |file_name|
|
|
281
298
|
result = ::File.open(file_name, "wb") { |io| builder.writer(io, &block) }
|
|
282
299
|
|
|
283
300
|
# Upload file only once all data has been written to it
|
|
@@ -288,6 +305,7 @@ module IOStreams
|
|
|
288
305
|
|
|
289
306
|
# Shortcut method if caller has a filename already with no other streams applied:
|
|
290
307
|
def write_file(file_name)
|
|
308
|
+
authorize!
|
|
291
309
|
if ::File.size(file_name) > MULTIPART_UPLOAD_SIZE
|
|
292
310
|
# Use multipart file upload
|
|
293
311
|
s3 = Aws::S3::Resource.new(client: client)
|
|
@@ -308,29 +326,26 @@ module IOStreams
|
|
|
308
326
|
case_sensitive: case_sensitive, directories: directories, hidden: hidden)
|
|
309
327
|
end
|
|
310
328
|
|
|
329
|
+
authorize!
|
|
311
330
|
matcher = Matcher.new(self, pattern, case_sensitive: case_sensitive, hidden: hidden)
|
|
312
331
|
|
|
313
332
|
# When the pattern includes an exact file name without any pattern characters
|
|
314
333
|
if matcher.pattern.nil?
|
|
315
|
-
yield(matcher.path) if matcher.path.exist?
|
|
334
|
+
yield(matcher.path) if allowed_child?(matcher.path) && matcher.path.exist?
|
|
316
335
|
return
|
|
317
336
|
end
|
|
318
337
|
|
|
319
338
|
prefix = Utils::URI.new(matcher.path.to_s).path.sub(%r{\A/}, "")
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
# Fetches upto 1,000 entries at a time
|
|
323
|
-
resp = client.list_objects_v2(bucket: bucket_name, prefix: prefix, continuation_token: token)
|
|
324
|
-
resp.contents.each do |object|
|
|
325
|
-
next if !directories && object.key.end_with?("/")
|
|
339
|
+
each_object(prefix) do |name, object|
|
|
340
|
+
next if !directories && object.key.end_with?("/")
|
|
326
341
|
|
|
327
|
-
|
|
328
|
-
|
|
342
|
+
file_name = ::File.join("s3://", name, object.key)
|
|
343
|
+
next unless matcher.match?(file_name)
|
|
329
344
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
345
|
+
child = child_path(name, object.key)
|
|
346
|
+
next unless allowed_child?(child)
|
|
347
|
+
|
|
348
|
+
yield(child, object.to_h)
|
|
334
349
|
end
|
|
335
350
|
nil
|
|
336
351
|
end
|
|
@@ -344,6 +359,42 @@ module IOStreams
|
|
|
344
359
|
def client
|
|
345
360
|
@client ||= ::Aws::S3::Client.new(@client_options)
|
|
346
361
|
end
|
|
362
|
+
|
|
363
|
+
private
|
|
364
|
+
|
|
365
|
+
# Yields the bucket name and each object in the bucket whose key starts with the supplied prefix.
|
|
366
|
+
def each_object(prefix)
|
|
367
|
+
token = nil
|
|
368
|
+
loop do
|
|
369
|
+
# Fetches upto 1,000 entries at a time
|
|
370
|
+
resp = client.list_objects_v2(bucket: bucket_name, prefix: prefix, continuation_token: token)
|
|
371
|
+
resp.contents.each { |object| yield(resp.name, object) }
|
|
372
|
+
token = resp.next_continuation_token
|
|
373
|
+
break if token.nil?
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Returns [String] the bucket and key, which is compared against the allowed paths.
|
|
378
|
+
#
|
|
379
|
+
# S3 treats `.` and `..` in a key as ordinary characters, but other services that implement the
|
|
380
|
+
# S3 API may resolve them, so keys containing them are denied.
|
|
381
|
+
def allowed_location
|
|
382
|
+
if path.split("/").intersect?([".", ".."])
|
|
383
|
+
raise(Errors::AccessDenied, "Access denied to #{self}: '.' and '..' are not allowed in S3 keys")
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
to_s.sub(%r{/+\z}, "")
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Set the key directly rather than parsing it as part of a URL, since a key can contain
|
|
390
|
+
# characters such as `?`, `+` or `%` that a URL parser would treat as a query or as escapes.
|
|
391
|
+
#
|
|
392
|
+
# The child uses this path's client, so that it has the same credentials and region.
|
|
393
|
+
def child_path(bucket_name, key)
|
|
394
|
+
child = self.class.new("s3://#{bucket_name}", client: client)
|
|
395
|
+
child.path = key.dup.freeze
|
|
396
|
+
child
|
|
397
|
+
end
|
|
347
398
|
end
|
|
348
399
|
end
|
|
349
400
|
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module IOStreams
|
|
2
|
+
module Paths
|
|
3
|
+
class SFTP < IOStreams::Path
|
|
4
|
+
# Translates the ssh options supplied for the sftp executable into options for net-ssh,
|
|
5
|
+
# which `SFTP#each_child` uses to list files instead of the sftp executable.
|
|
6
|
+
#
|
|
7
|
+
# The net-ssh options match those that SFTP passes to the sftp executable.
|
|
8
|
+
module NetSSH
|
|
9
|
+
# The ssh options that net-ssh supports.
|
|
10
|
+
OPTIONS = %w[
|
|
11
|
+
HostKey IdentityKey IdentityFile UserKnownHostsFile StrictHostKeyChecking
|
|
12
|
+
ConnectTimeout ServerAliveInterval ServerAliveCountMax LogLevel
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
# Yields [Hash] the net-ssh options.
|
|
16
|
+
#
|
|
17
|
+
# Raises [ArgumentError] when `ssh_options` includes an option that is not in `OPTIONS`.
|
|
18
|
+
def self.options(ssh_options, port:, password:)
|
|
19
|
+
options = {port: port, max_pkt_size: 65_536, non_interactive: true}
|
|
20
|
+
options[:logger] = IOStreams.logger if IOStreams.logger
|
|
21
|
+
# Like the sftp executable, only use the password when one is supplied, and otherwise only public keys.
|
|
22
|
+
if password
|
|
23
|
+
options[:password] = password
|
|
24
|
+
options[:auth_methods] = %w[password keyboard-interactive]
|
|
25
|
+
else
|
|
26
|
+
options[:auth_methods] = %w[publickey]
|
|
27
|
+
end
|
|
28
|
+
# Like the sftp executable, which uses `StrictHostKeyChecking=yes`, instead of the
|
|
29
|
+
# net-ssh default of trusting a host key the first time it is seen.
|
|
30
|
+
options[:verify_host_key] = :always
|
|
31
|
+
|
|
32
|
+
ssh_options.each_pair { |key, value| add(options, key, value) }
|
|
33
|
+
return yield(options) unless ssh_options.key?("HostKey")
|
|
34
|
+
|
|
35
|
+
# Like the sftp executable, the host key replaces the user's known_hosts file.
|
|
36
|
+
Utils.private_temp_file("iostreams-sftp-known-hosts") do |file_name|
|
|
37
|
+
::File.binwrite(file_name, ssh_options["HostKey"])
|
|
38
|
+
options[:user_known_hosts_file] = [file_name]
|
|
39
|
+
yield(options)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.add(options, key, value)
|
|
44
|
+
case key
|
|
45
|
+
when "HostKey"
|
|
46
|
+
# Written to a temp file by `.options`.
|
|
47
|
+
when "IdentityKey"
|
|
48
|
+
(options[:key_data] ||= []) << value
|
|
49
|
+
options[:keys_only] = true
|
|
50
|
+
when "IdentityFile"
|
|
51
|
+
(options[:keys] ||= []) << value
|
|
52
|
+
options[:keys_only] = true
|
|
53
|
+
when "UserKnownHostsFile"
|
|
54
|
+
options[:user_known_hosts_file] = value.to_s.split
|
|
55
|
+
when "StrictHostKeyChecking"
|
|
56
|
+
options[:verify_host_key] = verify_host_key(value)
|
|
57
|
+
when "ConnectTimeout"
|
|
58
|
+
options[:timeout] = Integer(value)
|
|
59
|
+
when "ServerAliveInterval"
|
|
60
|
+
options[:keepalive] = Integer(value).positive?
|
|
61
|
+
options[:keepalive_interval] = Integer(value)
|
|
62
|
+
when "ServerAliveCountMax"
|
|
63
|
+
options[:keepalive_maxcount] = Integer(value)
|
|
64
|
+
when "LogLevel"
|
|
65
|
+
options[:verbose] = log_level(value)
|
|
66
|
+
else
|
|
67
|
+
raise(ArgumentError,
|
|
68
|
+
"SFTP #each_child does not support the ssh option #{key.inspect}. It supports: #{OPTIONS.join(', ')}")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.verify_host_key(value)
|
|
73
|
+
case value.to_s.downcase
|
|
74
|
+
when "yes", "ask"
|
|
75
|
+
:always
|
|
76
|
+
when "accept-new"
|
|
77
|
+
:accept_new
|
|
78
|
+
when "no", "off"
|
|
79
|
+
:never
|
|
80
|
+
else
|
|
81
|
+
raise(ArgumentError, "Invalid StrictHostKeyChecking value: #{value.inspect}")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.log_level(value)
|
|
86
|
+
case value.to_s.upcase
|
|
87
|
+
when "QUIET", "FATAL"
|
|
88
|
+
:fatal
|
|
89
|
+
when "ERROR"
|
|
90
|
+
:error
|
|
91
|
+
when "INFO", "VERBOSE"
|
|
92
|
+
:info
|
|
93
|
+
when "DEBUG", "DEBUG1", "DEBUG2", "DEBUG3"
|
|
94
|
+
:debug
|
|
95
|
+
else
|
|
96
|
+
raise(ArgumentError, "Invalid LogLevel value: #{value.inspect}")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private_class_method :add, :verify_host_key, :log_level
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -23,8 +23,6 @@ module IOStreams
|
|
|
23
23
|
# output.write('Hello World')
|
|
24
24
|
# end
|
|
25
25
|
class SFTP < IOStreams::Path
|
|
26
|
-
include SemanticLogger::Loggable if defined?(SemanticLogger)
|
|
27
|
-
|
|
28
26
|
class << self
|
|
29
27
|
attr_accessor :sshpass_bin, :sftp_bin, :sshpass_wait_seconds, :before_password_wait_seconds
|
|
30
28
|
end
|
|
@@ -34,6 +32,8 @@ module IOStreams
|
|
|
34
32
|
@before_password_wait_seconds = 2
|
|
35
33
|
@sshpass_wait_seconds = 5
|
|
36
34
|
|
|
35
|
+
autoload :NetSSH, "io_streams/paths/sftp/net_ssh"
|
|
36
|
+
|
|
37
37
|
attr_reader :hostname, :username, :ssh_options, :url, :port
|
|
38
38
|
|
|
39
39
|
# Stream to a remote file over sftp.
|
|
@@ -41,6 +41,11 @@ module IOStreams
|
|
|
41
41
|
# url: [String]
|
|
42
42
|
# "sftp://<host_name>/<file_name>"
|
|
43
43
|
#
|
|
44
|
+
# SECURITY WARNING:
|
|
45
|
+
# A username and password supplied in the url remain part of it, so `#to_s` and `#url`
|
|
46
|
+
# return them, as does any log or error message that includes the path.
|
|
47
|
+
# Supply them with the `username:` and `password:` arguments instead.
|
|
48
|
+
#
|
|
44
49
|
# username: [String]
|
|
45
50
|
# Name of user to login with.
|
|
46
51
|
#
|
|
@@ -65,6 +70,11 @@ module IOStreams
|
|
|
65
70
|
# - Any other options supported by ssh_config.
|
|
66
71
|
# `man ssh_config` to see all available options.
|
|
67
72
|
#
|
|
73
|
+
# `#each_child` lists files with the net-sftp gem instead of the sftp executable, so it only supports
|
|
74
|
+
# these ssh options: HostKey, IdentityKey, IdentityFile, UserKnownHostsFile, StrictHostKeyChecking,
|
|
75
|
+
# ConnectTimeout, ServerAliveInterval, ServerAliveCountMax and LogLevel. Any other option raises
|
|
76
|
+
# ArgumentError.
|
|
77
|
+
#
|
|
68
78
|
# Examples:
|
|
69
79
|
#
|
|
70
80
|
# # Display the contents of a remote file
|
|
@@ -104,7 +114,7 @@ module IOStreams
|
|
|
104
114
|
# Not Ruby 2.5 yet: transform_keys(&:to_s)
|
|
105
115
|
@ssh_options = {}
|
|
106
116
|
ssh_options.each_pair { |key, value| @ssh_options[key.to_s] = value }
|
|
107
|
-
|
|
117
|
+
validate_username!
|
|
108
118
|
|
|
109
119
|
super(uri.path)
|
|
110
120
|
end
|
|
@@ -126,7 +136,9 @@ module IOStreams
|
|
|
126
136
|
|
|
127
137
|
# TODO: Add #copy_from shortcut to detect when a file is supplied that does not require conversion.
|
|
128
138
|
|
|
129
|
-
# Search for files on the remote sftp server that match the provided pattern.
|
|
139
|
+
# Search for files on the remote sftp server that match the provided pattern, within this path.
|
|
140
|
+
# When the url does not include a path, for example `sftp://sftp.example.org`, it searches
|
|
141
|
+
# the login directory.
|
|
130
142
|
#
|
|
131
143
|
# The pattern matching works like Net::SFTP::Operations::Dir.glob and Dir.glob
|
|
132
144
|
# Each child also returns attributes that contain the file size, ownership, file dates and other details.
|
|
@@ -139,7 +151,7 @@ module IOStreams
|
|
|
139
151
|
# end
|
|
140
152
|
#
|
|
141
153
|
# Example Output:
|
|
142
|
-
# sftp://sftp.example.org/a/b/c/test.txt {:type=>1, :size=>37, :owner=>"test_owner", :group=>"test_group",
|
|
154
|
+
# sftp://sftp.example.org/my_files/a/b/c/test.txt {:type=>1, :size=>37, :owner=>"test_owner", :group=>"test_group",
|
|
143
155
|
# :permissions=>420, :atime=>1572378136, :mtime=>1572378136, :link_count=>1, :extended=>{}}
|
|
144
156
|
def each_child(pattern = "*", case_sensitive: true, directories: false, hidden: false)
|
|
145
157
|
unless block_given?
|
|
@@ -147,64 +159,95 @@ module IOStreams
|
|
|
147
159
|
case_sensitive: case_sensitive, directories: directories, hidden: hidden)
|
|
148
160
|
end
|
|
149
161
|
|
|
162
|
+
authorize!
|
|
150
163
|
Utils.load_soft_dependency("net-sftp", "SFTP glob capability", "net/sftp") unless defined?(Net::SFTP)
|
|
151
164
|
|
|
152
165
|
flags = ::File::FNM_EXTGLOB
|
|
153
166
|
flags |= ::File::FNM_CASEFOLD unless case_sensitive
|
|
154
167
|
flags |= ::File::FNM_DOTMATCH if hidden
|
|
155
168
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
169
|
+
NetSSH.options(ssh_options, port: port, password: password) do |options|
|
|
170
|
+
Net::SFTP.start(hostname, username, options) do |sftp|
|
|
171
|
+
# Without a path in the url, list the login directory.
|
|
172
|
+
sftp.dir.glob(path.empty? ? "." : path, pattern, flags) do |entry|
|
|
173
|
+
next if !directories && !entry.file?
|
|
159
174
|
|
|
160
|
-
|
|
161
|
-
|
|
175
|
+
child = child_path(entry.name)
|
|
176
|
+
yield(child, entry.attributes.attributes) if allowed_child?(child)
|
|
177
|
+
end
|
|
162
178
|
end
|
|
163
179
|
end
|
|
164
180
|
nil
|
|
165
181
|
end
|
|
166
182
|
|
|
183
|
+
protected
|
|
184
|
+
|
|
185
|
+
attr_writer :url
|
|
186
|
+
|
|
167
187
|
private
|
|
168
188
|
|
|
169
189
|
attr_reader :password
|
|
170
190
|
|
|
191
|
+
# Returns [String] the host, port and path, which is compared against the allowed paths.
|
|
192
|
+
# `.` and `..` are resolved the way the sftp server resolves them.
|
|
193
|
+
def allowed_location
|
|
194
|
+
"sftp://#{hostname.to_s.downcase}:#{port}#{normalize_path(path)}".chomp("/")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Usernames are passed to the `sftp` executable, so reject values that it could treat as options.
|
|
198
|
+
def validate_username!
|
|
199
|
+
return if username.nil?
|
|
200
|
+
return unless username.to_s.start_with?("-") || username.to_s.match?(/[[:cntrl:]]/)
|
|
201
|
+
|
|
202
|
+
raise(ArgumentError, "Invalid SFTP username: it cannot start with '-' or contain control characters")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Set the path directly rather than parsing it as part of a URL, since a file name can contain
|
|
206
|
+
# characters such as `?`, `#`, `+` or `%` that a URL parser would treat as a query or as escapes.
|
|
207
|
+
#
|
|
208
|
+
# The supplied name is relative to this path, or to the login directory when this url has no path.
|
|
209
|
+
def child_path(name)
|
|
210
|
+
server = port == 22 ? "sftp://#{hostname}" : "sftp://#{hostname}:#{port}"
|
|
211
|
+
child = self.class.new(server, username: username, password: password, ssh_options: ssh_options)
|
|
212
|
+
child.path = (path.empty? ? "/#{name}" : ::File.join(path, name)).freeze
|
|
213
|
+
child.url = "#{server}#{child.path}"
|
|
214
|
+
child
|
|
215
|
+
end
|
|
216
|
+
|
|
171
217
|
def stream_reader(&block)
|
|
172
|
-
|
|
173
|
-
sftp_download(path,
|
|
174
|
-
::File.open(
|
|
218
|
+
Utils.private_temp_file("iostreams-sftp-reader") do |file_name|
|
|
219
|
+
sftp_download(path, file_name)
|
|
220
|
+
::File.open(file_name, "rb") { |io| builder.reader(io, &block) }
|
|
175
221
|
end
|
|
176
222
|
end
|
|
177
223
|
|
|
178
224
|
def stream_writer(&block)
|
|
179
|
-
|
|
180
|
-
::File.open(
|
|
181
|
-
sftp_upload(
|
|
182
|
-
|
|
225
|
+
Utils.private_temp_file("iostreams-sftp-writer") do |file_name|
|
|
226
|
+
::File.open(file_name, "wb") { |io| builder.writer(io, &block) }
|
|
227
|
+
sftp_upload(file_name, path)
|
|
228
|
+
::File.size(file_name)
|
|
183
229
|
end
|
|
184
230
|
end
|
|
185
231
|
|
|
186
|
-
# Use sftp
|
|
232
|
+
# Use the sftp executable to download to a local file, via sshpass when a password is supplied
|
|
187
233
|
def sftp_download(remote_file_name, local_file_name)
|
|
188
234
|
with_sftp_args do |args|
|
|
189
235
|
Open3.popen2e(*args) do |writer, reader, waith_thr|
|
|
190
|
-
|
|
191
|
-
|
|
236
|
+
if password
|
|
237
|
+
# Give time for remote sftp server to get ready to accept the password.
|
|
238
|
+
sleep self.class.before_password_wait_seconds
|
|
192
239
|
|
|
193
|
-
|
|
240
|
+
writer.puts password
|
|
194
241
|
|
|
195
|
-
|
|
196
|
-
|
|
242
|
+
# Give time for password to be processed and stdin to be passed to sftp process.
|
|
243
|
+
sleep self.class.sshpass_wait_seconds
|
|
244
|
+
end
|
|
197
245
|
|
|
198
|
-
writer.puts "get #{remote_file_name} #{local_file_name}"
|
|
246
|
+
writer.puts "get #{remote_file_name.inspect} #{local_file_name.inspect}"
|
|
199
247
|
writer.puts "bye"
|
|
200
248
|
writer.close
|
|
201
249
|
out = reader.read.chomp
|
|
202
|
-
unless waith_thr.value.success?
|
|
203
|
-
raise(
|
|
204
|
-
Errors::CommunicationsFailure,
|
|
205
|
-
"Download failed calling #{self.class.sftp_bin} via #{self.class.sshpass_bin}: #{out}"
|
|
206
|
-
)
|
|
207
|
-
end
|
|
250
|
+
raise_failure("Download", out) unless waith_thr.value.success?
|
|
208
251
|
|
|
209
252
|
out
|
|
210
253
|
rescue Errno::EPIPE
|
|
@@ -213,10 +256,7 @@ module IOStreams
|
|
|
213
256
|
rescue StandardError
|
|
214
257
|
nil
|
|
215
258
|
end
|
|
216
|
-
|
|
217
|
-
Errors::CommunicationsFailure,
|
|
218
|
-
"Download failed calling #{self.class.sftp_bin} via #{self.class.sshpass_bin}: #{out}"
|
|
219
|
-
)
|
|
259
|
+
raise_failure("Download", out)
|
|
220
260
|
end
|
|
221
261
|
end
|
|
222
262
|
end
|
|
@@ -224,19 +264,16 @@ module IOStreams
|
|
|
224
264
|
def sftp_upload(local_file_name, remote_file_name)
|
|
225
265
|
with_sftp_args do |args|
|
|
226
266
|
Open3.popen2e(*args) do |writer, reader, waith_thr|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
267
|
+
if password
|
|
268
|
+
writer.puts(password)
|
|
269
|
+
# Give time for password to be processed and stdin to be passed to sftp process.
|
|
270
|
+
sleep self.class.sshpass_wait_seconds
|
|
271
|
+
end
|
|
230
272
|
writer.puts "put #{local_file_name.inspect} #{remote_file_name.inspect}"
|
|
231
273
|
writer.puts "bye"
|
|
232
274
|
writer.close
|
|
233
275
|
out = reader.read.chomp
|
|
234
|
-
unless waith_thr.value.success?
|
|
235
|
-
raise(
|
|
236
|
-
Errors::CommunicationsFailure,
|
|
237
|
-
"Upload failed calling #{self.class.sftp_bin} via #{self.class.sshpass_bin}: #{out}"
|
|
238
|
-
)
|
|
239
|
-
end
|
|
276
|
+
raise_failure("Upload", out) unless waith_thr.value.success?
|
|
240
277
|
|
|
241
278
|
out
|
|
242
279
|
rescue Errno::EPIPE
|
|
@@ -245,14 +282,21 @@ module IOStreams
|
|
|
245
282
|
rescue StandardError
|
|
246
283
|
nil
|
|
247
284
|
end
|
|
248
|
-
|
|
249
|
-
Errors::CommunicationsFailure,
|
|
250
|
-
"Upload failed calling #{self.class.sftp_bin} via #{self.class.sshpass_bin}: #{out}"
|
|
251
|
-
)
|
|
285
|
+
raise_failure("Upload", out)
|
|
252
286
|
end
|
|
253
287
|
end
|
|
254
288
|
end
|
|
255
289
|
|
|
290
|
+
# When the server does not prompt for a password, sftp reads the password line as a command
|
|
291
|
+
# and echoes it in its output, so remove it before the output is included in the error.
|
|
292
|
+
def raise_failure(action, out)
|
|
293
|
+
out = out.gsub(password.to_s, "[FILTERED]") if out && !password.to_s.empty?
|
|
294
|
+
raise(
|
|
295
|
+
Errors::CommunicationsFailure,
|
|
296
|
+
"#{action} failed calling #{self.class.sftp_bin}#{" via #{self.class.sshpass_bin}" if password}: #{out}"
|
|
297
|
+
)
|
|
298
|
+
end
|
|
299
|
+
|
|
256
300
|
def with_sftp_args
|
|
257
301
|
return yield sftp_args(ssh_options) if !ssh_options.key?("IdentityKey") && !ssh_options.key?("HostKey")
|
|
258
302
|
|
|
@@ -276,9 +320,9 @@ module IOStreams
|
|
|
276
320
|
end
|
|
277
321
|
|
|
278
322
|
def with_temp_file(options, option, value)
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
::File.
|
|
323
|
+
# sftp requires that private key is only readable by the current user
|
|
324
|
+
Utils.private_temp_file("iostreams-sftp-args", "key") do |file_name|
|
|
325
|
+
::File.binwrite(file_name, value)
|
|
282
326
|
|
|
283
327
|
options[option] = file_name
|
|
284
328
|
yield options
|
|
@@ -286,7 +330,8 @@ module IOStreams
|
|
|
286
330
|
end
|
|
287
331
|
|
|
288
332
|
def sftp_args(ssh_options)
|
|
289
|
-
|
|
333
|
+
# sshpass is only needed to supply the password to sftp.
|
|
334
|
+
args = password ? [self.class.sshpass_bin, self.class.sftp_bin] : [self.class.sftp_bin]
|
|
290
335
|
# Force sftp to use the password when supplied,
|
|
291
336
|
# and stop sftp from prompting for a password when none was supplied.
|
|
292
337
|
if password
|
|
@@ -305,29 +350,23 @@ module IOStreams
|
|
|
305
350
|
ssh_options.each_pair { |key, value| args << "-o#{key}=#{value}" }
|
|
306
351
|
args << "-b"
|
|
307
352
|
args << "-"
|
|
353
|
+
# Stop sftp from treating the destination as an option.
|
|
354
|
+
args << "--"
|
|
308
355
|
args << "#{username}@#{hostname}"
|
|
309
356
|
args
|
|
310
357
|
end
|
|
311
358
|
|
|
312
|
-
def build_ssh_options
|
|
313
|
-
options = ssh_options.dup
|
|
314
|
-
options[:logger] ||= logger if defined?(SemanticLogger)
|
|
315
|
-
options[:port] ||= port
|
|
316
|
-
options[:max_pkt_size] ||= 65_536
|
|
317
|
-
options[:password] ||= @password
|
|
318
|
-
options
|
|
319
|
-
end
|
|
320
|
-
|
|
321
359
|
def map_log_level
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
case logger.level
|
|
360
|
+
level = IOStreams.logger&.level
|
|
361
|
+
case level
|
|
325
362
|
when :trace
|
|
326
363
|
"DEBUG3"
|
|
327
364
|
when :warn
|
|
328
365
|
"ERROR"
|
|
366
|
+
when Symbol
|
|
367
|
+
level.to_s
|
|
329
368
|
else
|
|
330
|
-
|
|
369
|
+
"INFO"
|
|
331
370
|
end
|
|
332
371
|
end
|
|
333
372
|
end
|