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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -13
  3. data/Rakefile +52 -0
  4. data/docs/CLAUDE.md +9 -0
  5. data/docs/config.md +157 -0
  6. data/docs/copy_files.md +75 -0
  7. data/docs/extensions.md +111 -0
  8. data/docs/formats.md +188 -0
  9. data/docs/index.md +388 -0
  10. data/docs/path.md +652 -0
  11. data/docs/pgp.md +436 -0
  12. data/docs/streams.md +337 -0
  13. data/docs/tutorial.md +483 -0
  14. data/docs/upgrading.md +217 -0
  15. data/lib/io_streams/builder.rb +71 -11
  16. data/lib/io_streams/bzip2/reader.rb +25 -2
  17. data/lib/io_streams/bzip2/writer.rb +26 -2
  18. data/lib/io_streams/encode/reader.rb +6 -2
  19. data/lib/io_streams/encode/writer.rb +9 -5
  20. data/lib/io_streams/errors.rb +4 -0
  21. data/lib/io_streams/gzip/reader.rb +5 -1
  22. data/lib/io_streams/gzip/writer.rb +11 -2
  23. data/lib/io_streams/io_streams.rb +156 -20
  24. data/lib/io_streams/line/reader.rb +9 -4
  25. data/lib/io_streams/line/writer.rb +1 -1
  26. data/lib/io_streams/path.rb +117 -8
  27. data/lib/io_streams/paths/file.rb +57 -11
  28. data/lib/io_streams/paths/http.rb +123 -9
  29. data/lib/io_streams/paths/matcher.rb +3 -3
  30. data/lib/io_streams/paths/s3.rb +69 -18
  31. data/lib/io_streams/paths/sftp/net_ssh.rb +104 -0
  32. data/lib/io_streams/paths/sftp.rb +103 -64
  33. data/lib/io_streams/pgp/reader.rb +63 -10
  34. data/lib/io_streams/pgp/writer.rb +111 -30
  35. data/lib/io_streams/pgp.rb +256 -71
  36. data/lib/io_streams/reader.rb +14 -5
  37. data/lib/io_streams/record/reader.rb +75 -6
  38. data/lib/io_streams/record/writer.rb +3 -4
  39. data/lib/io_streams/row/reader.rb +1 -1
  40. data/lib/io_streams/row/writer.rb +1 -1
  41. data/lib/io_streams/stream.rb +48 -37
  42. data/lib/io_streams/symmetric_encryption/reader.rb +6 -2
  43. data/lib/io_streams/symmetric_encryption/writer.rb +8 -4
  44. data/lib/io_streams/tabular/header.rb +49 -10
  45. data/lib/io_streams/tabular/parser/array.rb +0 -10
  46. data/lib/io_streams/tabular/parser/base.rb +10 -0
  47. data/lib/io_streams/tabular/parser/csv.rb +9 -36
  48. data/lib/io_streams/tabular/parser/fixed.rb +8 -6
  49. data/lib/io_streams/tabular/parser/psv.rb +6 -14
  50. data/lib/io_streams/tabular.rb +5 -10
  51. data/lib/io_streams/utils.rb +34 -2
  52. data/lib/io_streams/version.rb +1 -1
  53. data/lib/io_streams/writer.rb +16 -7
  54. data/lib/io_streams/xlsx/reader.rb +6 -2
  55. data/lib/io_streams/zip/reader.rb +4 -0
  56. data/lib/io_streams/zip/writer.rb +26 -10
  57. data/lib/iostreams.rb +0 -1
  58. metadata +46 -112
  59. data/lib/io_streams/deprecated.rb +0 -216
  60. data/lib/io_streams/tabular/utility/csv_row.rb +0 -105
  61. data/test/builder_test.rb +0 -311
  62. data/test/bzip2_reader_test.rb +0 -27
  63. data/test/bzip2_writer_test.rb +0 -56
  64. data/test/deprecated_test.rb +0 -121
  65. data/test/encode_reader_test.rb +0 -51
  66. data/test/encode_writer_test.rb +0 -90
  67. data/test/files/embedded_lines_test.csv +0 -7
  68. data/test/files/multiple_files.zip +0 -0
  69. data/test/files/spreadsheet.xlsx +0 -0
  70. data/test/files/test.csv +0 -4
  71. data/test/files/test.json +0 -3
  72. data/test/files/test.psv +0 -4
  73. data/test/files/text file.txt +0 -3
  74. data/test/files/text.txt +0 -3
  75. data/test/files/text.txt.bz2 +0 -0
  76. data/test/files/text.txt.gz +0 -0
  77. data/test/files/text.txt.gz.zip +0 -0
  78. data/test/files/text.zip +0 -0
  79. data/test/files/text.zip.gz +0 -0
  80. data/test/files/unclosed_quote_large_test.csv +0 -1658
  81. data/test/files/unclosed_quote_test.csv +0 -4
  82. data/test/files/unclosed_quote_test2.csv +0 -3
  83. data/test/gzip_reader_test.rb +0 -27
  84. data/test/gzip_writer_test.rb +0 -52
  85. data/test/io_streams_test.rb +0 -132
  86. data/test/line_reader_test.rb +0 -325
  87. data/test/line_writer_test.rb +0 -59
  88. data/test/minimal_file_reader.rb +0 -25
  89. data/test/path_test.rb +0 -55
  90. data/test/paths/file_test.rb +0 -213
  91. data/test/paths/http_test.rb +0 -34
  92. data/test/paths/matcher_test.rb +0 -120
  93. data/test/paths/s3_test.rb +0 -220
  94. data/test/paths/sftp_test.rb +0 -106
  95. data/test/pgp_reader_test.rb +0 -46
  96. data/test/pgp_test.rb +0 -267
  97. data/test/pgp_writer_test.rb +0 -130
  98. data/test/record_reader_test.rb +0 -60
  99. data/test/record_writer_test.rb +0 -82
  100. data/test/row_reader_test.rb +0 -35
  101. data/test/row_writer_test.rb +0 -56
  102. data/test/stream_test.rb +0 -577
  103. data/test/tabular_test.rb +0 -338
  104. data/test/test_helper.rb +0 -40
  105. data/test/utils_test.rb +0 -20
  106. data/test/xlsx_reader_test.rb +0 -37
  107. data/test/zip_reader_test.rb +0 -53
  108. data/test/zip_writer_test.rb +0 -48
@@ -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(target_path, convert: convert, **args) if convert || (size.to_i >= S3_COPY_OBJECT_SIZE_LIMIT)
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.temp_file_name("iostreams_s3") do |file_name|
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.temp_file_name("iostreams_s3") do |file_name|
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
- token = nil
321
- loop do
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
- file_name = ::File.join("s3://", resp.name, object.key)
328
- next unless matcher.match?(file_name)
342
+ file_name = ::File.join("s3://", name, object.key)
343
+ next unless matcher.match?(file_name)
329
344
 
330
- yield(self.class.new(file_name), object.to_h)
331
- end
332
- token = resp.next_continuation_token
333
- break if token.nil?
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
- @ssh_options.merge(uri.query) if uri.query
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
- Net::SFTP.start(hostname, username, build_ssh_options) do |sftp|
157
- sftp.dir.glob(".", pattern, flags) do |path|
158
- next if !directories && !path.file?
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
- new_path = self.class.new("sftp://#{hostname}/#{path.name}", username: username, password: password, **ssh_options)
161
- yield(new_path, path.attributes.attributes)
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
- IOStreams.temp_file("iostreams-sftp-reader") do |temp_file|
173
- sftp_download(path, temp_file.to_s)
174
- ::File.open(temp_file.to_s, "rb") { |io| builder.reader(io, &block) }
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
- IOStreams.temp_file("iostreams-sftp-writer") do |temp_file|
180
- ::File.open(temp_file.to_s, "wb") { |io| builder.writer(io, &block) }
181
- sftp_upload(temp_file.to_s, path)
182
- temp_file.size
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 and sshpass executables to download to a local file
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
- # Give time for remote sftp server to get ready to accept the password.
191
- sleep self.class.before_password_wait_seconds
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
- writer.puts password
240
+ writer.puts password
194
241
 
195
- # Give time for password to be processed and stdin to be passed to sftp process.
196
- sleep self.class.sshpass_wait_seconds
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
- raise(
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
- writer.puts(password) if password
228
- # Give time for password to be processed and stdin to be passed to sftp process.
229
- sleep self.class.sshpass_wait_seconds
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
- raise(
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
- Utils.temp_file_name("iostreams-sftp-args", "key") do |file_name|
280
- # sftp requires that private key is only readable by the current user
281
- ::File.open(file_name, "wb", 0o600) { |io| io.write(value) }
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
- args = [self.class.sshpass_bin, self.class.sftp_bin]
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
- return "INFO" unless defined?(SemanticLogger)
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
- logger.level.to_s
369
+ "INFO"
331
370
  end
332
371
  end
333
372
  end