net-sftp 4.0.0 → 4.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.
@@ -1,3 +1,4 @@
1
+ require 'pathname'
1
2
  require 'net/ssh/loggable'
2
3
 
3
4
  module Net; module SFTP; module Operations
@@ -48,12 +49,26 @@ module Net; module SFTP; module Operations
48
49
  # * <tt>:progress</tt> - either a block or an object to act as a progress
49
50
  # callback. See the discussion of "progress monitoring" below.
50
51
  # * <tt>:requests</tt> - the number of pending SFTP requests to allow at
51
- # any given time. When downloading an entire directory tree recursively,
52
- # this will default to 16. Setting this higher might improve throughput.
53
- # Reducing it will reduce throughput.
52
+ # any given time. When downloading a single file, this is the number of
53
+ # reads kept in flight and defaults to 64. When downloading an entire
54
+ # directory tree recursively, it is the number of files and directories
55
+ # transferred at once and defaults to 16. Setting this higher might
56
+ # improve throughput, particularly on high-latency links. Reducing it
57
+ # will reduce throughput.
54
58
  # * <tt>:read_size</tt> - the maximum number of bytes to read at a time
55
59
  # from the source. Increasing this value might improve throughput. It
56
60
  # defaults to 32,000 bytes.
61
+ # * <tt>:preserve</tt> - if true, the local files and directories get the
62
+ # access and modification times of the remote ones, and their permissions
63
+ # are set exactly, even on files that already exist and regardless of the
64
+ # umask.
65
+ #
66
+ # Like OpenSSH's sftp client, new local files and directories are created
67
+ # with the permissions of the remote ones, without setuid, setgid and sticky
68
+ # bits and subject to the umask. Nothing is set when +local+ is an IO object.
69
+ # If the remote attributes can't be read, the download goes ahead with
70
+ # default permissions; if they can't be applied locally, a warning is logged
71
+ # and the download still succeeds.
57
72
  #
58
73
  # == Progress Monitoring
59
74
  #
@@ -145,13 +160,19 @@ module Net; module SFTP; module Operations
145
160
  # This will return immediately, and requires that the SSH event loop be
146
161
  # run in order to effect the download. (See #wait.)
147
162
  def initialize(sftp, local, remote, options={}, &progress)
163
+ local = local.to_path if local.is_a?(Pathname)
164
+ remote = remote.to_path if remote.is_a?(Pathname)
165
+
148
166
  @sftp = sftp
149
167
  @local = local
150
168
  @remote = remote
151
169
  @progress = progress || options[:progress]
152
170
  @options = options
153
171
  @active = 0
172
+ @directories = []
154
173
  @properties = options[:properties] || {}
174
+ requests
175
+ read_size
155
176
 
156
177
  self.logger = sftp.logger
157
178
 
@@ -204,7 +225,8 @@ module Net; module SFTP; module Operations
204
225
 
205
226
  # A simple struct for encapsulating information about a single remote
206
227
  # file or directory that needs to be downloaded.
207
- Entry = Struct.new(:remote, :local, :directory, :size, :handle, :offset, :sink)
228
+ Entry = Struct.new(:remote, :local, :directory, :size, :handle, :offset, :sink,
229
+ :next_read, :pending, :window, :chunks, :eof, :attributes)
208
230
 
209
231
  #--
210
232
  # "ruby -w" hates private attributes, so we have to do these longhand
@@ -222,13 +244,29 @@ module Net; module SFTP; module Operations
222
244
 
223
245
  # The number of bytes to read at a time from remote files.
224
246
  def read_size
225
- options[:read_size] || DEFAULT_READ_SIZE
247
+ @read_size ||= positive_option(:read_size, DEFAULT_READ_SIZE)
226
248
  end
227
249
 
228
- # The number of simultaneou SFTP requests to use to effect the download.
229
- # Defaults to 16 for recursive downloads.
250
+ # The number of simultaneous SFTP requests to use to effect the download.
251
+ # Defaults to 16 for recursive downloads and 64 for single files.
230
252
  def requests
231
- options[:requests] || (recursive? ? 16 : 2)
253
+ @requests ||= positive_option(:requests, recursive? ? 16 : 64)
254
+ end
255
+
256
+ # The most reads a single file may have outstanding at once.
257
+ def read_window_limit
258
+ recursive? ? 1 : requests
259
+ end
260
+
261
+ def preserve?
262
+ options[:preserve]
263
+ end
264
+
265
+ def positive_option(name, default)
266
+ value = (options[name] || default).to_i
267
+ raise ArgumentError, ":#{name} must be positive" unless value > 0
268
+
269
+ value
232
270
  end
233
271
 
234
272
  # Enqueues as many files and directories from the stack as possible
@@ -240,7 +278,13 @@ module Net; module SFTP; module Operations
240
278
 
241
279
  if entry.directory
242
280
  update_progress(:mkdir, entry.local)
243
- ::Dir.mkdir(entry.local) unless ::File.directory?(entry.local)
281
+ refuse_symlink!(entry.local) if server_named?(entry) && ::File.symlink?(entry.local)
282
+ created = !::File.directory?(entry.local)
283
+ ::Dir.mkdir(entry.local, local_permissions(entry, 0777) | 0300) if created
284
+ if created || preserve?
285
+ @directories << entry
286
+ stat_directory(entry) unless entry.attributes
287
+ end
244
288
  request = sftp.opendir(entry.remote, &method(:on_opendir))
245
289
  request[:entry] = entry
246
290
  else
@@ -248,7 +292,64 @@ module Net; module SFTP; module Operations
248
292
  end
249
293
  end
250
294
 
251
- update_progress(:finish) if !active?
295
+ if !active?
296
+ set_directory_attributes
297
+ update_progress(:finish)
298
+ end
299
+ end
300
+
301
+ # Only entries listed by readdir come with attributes.
302
+ def stat_directory(entry)
303
+ @active += 1
304
+ sftp.stat(entry.remote) do |response|
305
+ @active -= 1
306
+ entry.attributes = remote_attributes(response, "stat #{entry.remote}")
307
+ process_next_entry
308
+ end
309
+ end
310
+
311
+ # Without the remote attributes the download goes ahead with default
312
+ # permissions and no times, as it would for a server that never sent any.
313
+ def remote_attributes(response, operation)
314
+ return response[:attrs] if response.ok?
315
+
316
+ debug { "#{operation} failed, using default attributes: #{response}" }
317
+ nil
318
+ end
319
+
320
+ # Deepest directories first, after their contents; setting attributes on
321
+ # a directory doesn't change its parent's modification time.
322
+ def set_directory_attributes
323
+ @directories.reverse_each do |entry|
324
+ next unless entry.attributes
325
+
326
+ permissions = local_permissions(entry, 0777)
327
+ permissions &= ~::File.umask unless preserve?
328
+ set_local_attributes(entry, permissions)
329
+ end
330
+ @directories.clear
331
+ end
332
+
333
+ # setuid, setgid and sticky bits are never applied, matching OpenSSH.
334
+ def local_permissions(entry, default)
335
+ permissions = entry.attributes && entry.attributes.permissions
336
+ permissions ? permissions & 0777 : default
337
+ end
338
+
339
+ # Like OpenSSH's sftp client, failing to set attributes only warns; the
340
+ # data has already been transferred.
341
+ def set_local_attributes(entry, permissions)
342
+ attempt_local("chmod", entry) { ::File.chmod(permissions, entry.local) } if permissions
343
+
344
+ attributes = entry.attributes
345
+ return unless preserve? && attributes && attributes.atime && attributes.mtime
346
+ attempt_local("utime", entry) { ::File.utime(attributes.atime, attributes.mtime, entry.local) }
347
+ end
348
+
349
+ def attempt_local(operation, entry)
350
+ yield
351
+ rescue SystemCallError => e
352
+ lwarn { "#{operation} #{entry.local} failed: #{e.message}" }
252
353
  end
253
354
 
254
355
  # Called when a remote directory is "opened" for reading, e.g. to
@@ -275,7 +376,10 @@ module Net; module SFTP; module Operations
275
376
  else
276
377
  response[:names].each do |item|
277
378
  next if item.name == "." || item.name == ".."
278
- stack << Entry.new(::File.join(entry.remote, item.name), ::File.join(entry.local, item.name), item.directory?, item.attributes.size)
379
+ validate_entry_name!(entry, item.name)
380
+ child = Entry.new(::File.join(entry.remote, item.name), ::File.join(entry.local, item.name), item.directory?, item.attributes.size)
381
+ child.attributes = item.attributes
382
+ stack << child
279
383
  end
280
384
 
281
385
  # take this opportunity to enqueue more requests
@@ -286,6 +390,38 @@ module Net; module SFTP; module Operations
286
390
  end
287
391
  end
288
392
 
393
+ # The name comes from the server, so anything but a single path component
394
+ # would let a malicious server write outside the target directory.
395
+ def validate_entry_name!(entry, name)
396
+ if name.empty? || name.include?("/") || name.include?("\0") ||
397
+ (::File::ALT_SEPARATOR && name.include?(::File::ALT_SEPARATOR))
398
+ raise Net::SFTP::Exception, "readdir #{entry.remote} returned an invalid file name: #{name.inspect}"
399
+ end
400
+ end
401
+
402
+ # The caller's own +local+ may be a symlink on purpose; anything below it
403
+ # was named by the server (net-sftp#162).
404
+ def server_named?(entry)
405
+ entry.local != local
406
+ end
407
+
408
+ # Like OpenSSH, the file is always created writable by its owner.
409
+ def open_local_file(entry)
410
+ permissions = local_permissions(entry, 0666) | 0200
411
+ return ::File.open(entry.local, "wb", permissions) unless server_named?(entry) && defined?(::File::NOFOLLOW)
412
+
413
+ begin
414
+ ::File.open(entry.local, "wb", permissions, :flags => ::File::NOFOLLOW)
415
+ rescue SystemCallError
416
+ raise unless ::File.symlink?(entry.local)
417
+ refuse_symlink!(entry.local)
418
+ end
419
+ end
420
+
421
+ def refuse_symlink!(path)
422
+ raise Net::SFTP::Exception, "#{path} is a symlink, refusing to follow it"
423
+ end
424
+
289
425
  # Called when a file is to be opened for reading from the remote server.
290
426
  def open_file(entry)
291
427
  update_progress(:open, entry)
@@ -301,45 +437,116 @@ module Net; module SFTP; module Operations
301
437
  process_next_entry
302
438
  end
303
439
 
304
- # Called when a file has been opened. This will call #download_next_chunk
440
+ # Called when a file has been opened. This will call #request_reads
305
441
  # to initiate the data transfer.
306
442
  def on_open(response)
307
443
  entry = response.request[:entry]
308
444
  raise StatusException.new(response, "open #{entry.remote}") unless response.ok?
309
445
 
310
446
  entry.handle = response[:handle]
311
- entry.sink = entry.local.respond_to?(:write) ? entry.local : ::File.open(entry.local, "wb")
447
+ if entry.attributes || entry.local.respond_to?(:write)
448
+ start_download(entry)
449
+ else
450
+ request = sftp.fstat(entry.handle, &method(:on_fstat))
451
+ request[:entry] = entry
452
+ end
453
+ end
454
+
455
+ # Called when the attributes of a file opened by #open_file arrive.
456
+ def on_fstat(response)
457
+ entry = response.request[:entry]
458
+ entry.attributes = remote_attributes(response, "fstat #{entry.remote}")
459
+ start_download(entry)
460
+ end
461
+
462
+ def start_download(entry)
463
+ entry.sink = entry.local.respond_to?(:write) ? entry.local : open_local_file(entry)
312
464
  entry.offset = 0
465
+ entry.next_read = 0
466
+ entry.pending = 0
467
+ entry.window = 1
468
+ entry.chunks = {}
469
+
470
+ request_reads(entry)
471
+ end
313
472
 
314
- download_next_chunk(entry)
473
+ # Keeps up to entry.window reads of #read_size bytes outstanding until
474
+ # the server reports EOF. The window starts at one and grows with each
475
+ # data reply, so small files don't cost a burst of reads past their end.
476
+ def request_reads(entry)
477
+ while !entry.eof && entry.pending < entry.window
478
+ read_range(entry, entry.next_read, read_size)
479
+ entry.next_read += read_size
480
+ end
315
481
  end
316
482
 
317
- # Initiates a read of the next #read_size bytes from the file.
318
- def download_next_chunk(entry)
319
- request = sftp.read(entry.handle, entry.offset, read_size, &method(:on_read))
483
+ def read_range(entry, offset, length)
484
+ entry.pending += 1
485
+ request = sftp.read(entry.handle, offset, length, &method(:on_read))
320
486
  request[:entry] = entry
321
- request[:offset] = entry.offset
487
+ request[:offset] = offset
488
+ request[:length] = length
322
489
  end
323
490
 
324
- # Called when a read from a file finishes. If the read was successful
325
- # and returned data, this will call #download_next_chunk to read the
326
- # next bit from the file. Otherwise the file will be closed.
491
+ # Called when a read from a file finishes. Replies can arrive out of
492
+ # order, so data is buffered until everything before it has been
493
+ # written. Once the server has reported EOF and no reads are left
494
+ # outstanding, the file is closed.
327
495
  def on_read(response)
328
496
  entry = response.request[:entry]
497
+ entry.pending -= 1
329
498
 
330
499
  if response.eof?
331
- update_progress(:close, entry)
332
- entry.sink.close
333
- request = sftp.close(entry.handle, &method(:on_close))
334
- request[:entry] = entry
500
+ entry.eof = true
335
501
  elsif !response.ok?
336
502
  raise StatusException.new(response, "read #{entry.remote}")
337
503
  else
338
- entry.offset += response[:data].bytesize
339
- update_progress(:get, entry, response.request[:offset], response[:data])
340
- entry.sink.write(response[:data])
341
- download_next_chunk(entry)
504
+ offset = response.request[:offset]
505
+ length = response.request[:length]
506
+ data = response[:data]
507
+
508
+ if data.empty? || data.bytesize > length
509
+ raise Net::SFTP::Exception, "read #{entry.remote} returned #{data.bytesize} bytes for a #{length} byte request"
510
+ end
511
+
512
+ # Servers may return less than asked for without being at EOF, and
513
+ # the reads after this one have already been sent.
514
+ read_range(entry, offset + data.bytesize, length - data.bytesize) if data.bytesize < length
515
+
516
+ entry.chunks[offset] = data
517
+ write_buffered_chunks(entry)
518
+ entry.window += 1 if entry.window < read_window_limit
342
519
  end
520
+
521
+ request_reads(entry)
522
+ close_file(entry) if entry.pending.zero?
523
+ end
524
+
525
+ def write_buffered_chunks(entry)
526
+ while (data = entry.chunks.delete(entry.offset))
527
+ update_progress(:get, entry, entry.offset, data)
528
+ entry.sink.write(data)
529
+ entry.offset += data.bytesize
530
+ end
531
+ end
532
+
533
+ def close_file(entry)
534
+ if entry.chunks.any?
535
+ raise Net::SFTP::Exception, "read #{entry.remote} hit EOF at #{entry.offset} but the server returned data beyond it"
536
+ end
537
+
538
+ update_progress(:close, entry)
539
+ # The caller's IO stays open, but is flushed so its data is written when download! returns.
540
+ if entry.sink.equal?(local)
541
+ entry.sink.flush if entry.sink.respond_to?(:flush)
542
+ else
543
+ entry.sink.close
544
+ end
545
+ unless entry.local.respond_to?(:write)
546
+ set_local_attributes(entry, preserve? ? local_permissions(entry, nil) : nil)
547
+ end
548
+ request = sftp.close(entry.handle, &method(:on_close))
549
+ request[:entry] = entry
343
550
  end
344
551
 
345
552
  # Called when a file handle is closed.
@@ -12,6 +12,11 @@ module Net; module SFTP; module Operations
12
12
  # puts file.gets
13
13
  # file.close
14
14
  class File
15
+ # The largest amount of data sent in a single write request. Servers drop
16
+ # the connection on oversized packets (OpenSSH's limit is 256 KiB including
17
+ # headers), and the SFTP drafts only guarantee packets of about 34000 bytes.
18
+ MAX_WRITE_SIZE = 32 * 1024
19
+
15
20
  # A reference to the Net::SFTP::Session instance that drives this wrapper
16
21
  attr_reader :sftp
17
22
 
@@ -30,14 +35,14 @@ module Net; module SFTP; module Operations
30
35
  @pos = 0
31
36
  @real_pos = 0
32
37
  @real_eof = false
33
- @buffer = ""
38
+ @buffer = +""
34
39
  end
35
40
 
36
41
  # Repositions the file pointer to the given offset (relative to the
37
42
  # start of the file). This will also reset the EOF flag.
38
43
  def pos=(offset)
39
44
  @real_pos = @pos = offset
40
- @buffer = ""
45
+ @buffer = +""
41
46
  @real_eof = false
42
47
  end
43
48
 
@@ -63,15 +68,17 @@ module Net; module SFTP; module Operations
63
68
  #
64
69
  # This will advance the file pointer (#pos).
65
70
  def read(n=nil)
71
+ raise ArgumentError, "negative length #{n} given" if n && n < 0
72
+
66
73
  loop do
67
74
  break if n && @buffer.length >= n
68
75
  break unless fill
69
76
  end
70
77
 
71
78
  if n
72
- result, @buffer = @buffer[0,n], (@buffer[n..-1] || "")
79
+ result, @buffer = @buffer[0,n], (@buffer[n..-1] || +"")
73
80
  else
74
- result, @buffer = @buffer, ""
81
+ result, @buffer = @buffer, +""
75
82
  end
76
83
 
77
84
  @pos += result.length
@@ -92,6 +99,7 @@ module Net; module SFTP; module Operations
92
99
  sep_string = sep_or_limit
93
100
  lim = limit
94
101
  end
102
+ lim = Float::INFINITY if lim.nil? || lim < 0
95
103
 
96
104
  delim = if sep_string && sep_string.length == 0
97
105
  "#{$/}#{$/}"
@@ -99,22 +107,26 @@ module Net; module SFTP; module Operations
99
107
  sep_string
100
108
  end
101
109
 
110
+ search_from = 0
102
111
  loop do
103
- at = @buffer.index(delim) if delim
112
+ at = @buffer.index(delim, search_from) if delim
104
113
  if at
105
114
  offset = [at + delim.length, lim].min
106
115
  @pos += offset
107
116
  line, @buffer = @buffer[0,offset], @buffer[offset..-1]
108
117
  return line
109
- elsif lim < @buffer.length
118
+ elsif lim <= @buffer.length
110
119
  @pos += lim
111
120
  line, @buffer = @buffer[0,lim], @buffer[lim..-1]
112
121
  return line
113
- elsif !fill
114
- return nil if @buffer.empty?
115
- @pos += @buffer.length
116
- line, @buffer = @buffer, ""
117
- return line
122
+ else
123
+ search_from = [@buffer.length - delim.length + 1, 0].max if delim
124
+ unless fill
125
+ return nil if @buffer.empty?
126
+ @pos += @buffer.length
127
+ line, @buffer = @buffer, +""
128
+ return line
129
+ end
118
130
  end
119
131
  end
120
132
  end
@@ -128,13 +140,21 @@ module Net; module SFTP; module Operations
128
140
  end
129
141
 
130
142
  # Writes the given data to the stream, incrementing the file position and
131
- # returning the number of bytes written.
143
+ # returning the number of bytes written. Data larger than MAX_WRITE_SIZE
144
+ # is sent as several sequential write requests; if one fails, the
145
+ # position reflects the bytes written before it.
132
146
  def write(data)
133
147
  data = data.to_s
134
- sftp.write!(handle, @real_pos, data)
135
- @real_pos += data.bytes.length
136
- @pos = @real_pos
137
- data.bytes.length
148
+ size = data.bytesize
149
+ written = 0
150
+ loop do
151
+ chunk = data.byteslice(written, MAX_WRITE_SIZE)
152
+ sftp.write!(handle, @pos, chunk)
153
+ written += chunk.bytesize
154
+ self.pos = @pos + chunk.bytesize unless chunk.empty?
155
+ break if written >= size
156
+ end
157
+ size
138
158
  end
139
159
 
140
160
  # Writes each argument to the stream. If +$\+ is set, it will be written
@@ -155,16 +175,12 @@ module Net; module SFTP; module Operations
155
175
  end
156
176
 
157
177
  # Writes each argument to the stream, appending a newline to any item
158
- # that does not already end in a newline. Array arguments are flattened.
178
+ # that does not already end in a newline, like IO#puts. Array arguments
179
+ # are flattened, and with no arguments a single newline is written.
159
180
  def puts(*items)
160
- items.each do |item|
161
- if Array === item
162
- puts(*item)
163
- else
164
- write(item)
165
- write("\n") unless item[-1] == ?\n
166
- end
167
- end
181
+ items = [""] if items.empty?
182
+ recursion = {}.compare_by_identity
183
+ items.each { |item| write_puts_item(item, recursion) }
168
184
  nil
169
185
  end
170
186
 
@@ -178,12 +194,30 @@ module Net; module SFTP; module Operations
178
194
 
179
195
  private
180
196
 
197
+ def write_puts_item(item, recursion)
198
+ array = Array.try_convert(item)
199
+ if array
200
+ if recursion.key?(item)
201
+ item = "[...]"
202
+ else
203
+ recursion[item] = true
204
+ array.each { |child| write_puts_item(child, recursion) }
205
+ recursion.delete(item)
206
+ return
207
+ end
208
+ end
209
+
210
+ item = item.to_s
211
+ write(item) unless item.empty?
212
+ write("\n") unless item[-1] == ?\n
213
+ end
214
+
181
215
  # Fills the buffer. Returns +true+ if it succeeded, and +false+ if
182
216
  # EOF was encountered before any data was read.
183
217
  def fill
184
218
  data = sftp.read!(handle, @real_pos, 8192)
185
219
 
186
- if data.nil?
220
+ if data.nil? || data.empty?
187
221
  @real_eof = true
188
222
  return false
189
223
  else