net-sftp 4.0.2 → 4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e7dc5adb1f352eecedf9c4d5f6150b32e0e26ccdd76cb6cb242c66fbe636fd4
4
- data.tar.gz: e9b3ade5165aa0c1dbb61e64acd303794b16a31aa385472c3c513d983322fb3a
3
+ metadata.gz: 618cad45c3de8f52cf2579f2616d27df2ecd69d35dce35c6b80822dab1a0f8ac
4
+ data.tar.gz: 7b07b32f66e9109e037582827bc2dc602b19479d93b6be3e526f263cd499062e
5
5
  SHA512:
6
- metadata.gz: 8534ab3b4109bd1e5333c1e4814e5e01a855c2f84c40f60d58bfb2d6303bdc1fab20bf8102b31ef2a1b8c408e6565a7a0a26ff1dbf1b1c48445dd2f9aa49bee8
7
- data.tar.gz: b36ad98cdca42bdf48a29957105f4a104c2965280f983f937bfd64e8958b86a2354dc612e52404bba88374944bdda86016f4e06449e8fb541e26f74a0e0eeacc
6
+ metadata.gz: 47d88176bf4883648da4339a5d250e69c84921bc96a5424ff9bb5bb65d4c83b97fa2543e2a11f03d3043c6a04bf8bde5026c167ef6ccd9c1d880f7e8dcb08d10
7
+ data.tar.gz: c7568857412af07782a37dbc7516fb59af76e47bff76e9229a9fa2429076fca96ca06d92b6f9706de20c700917ecb887ea8e793d23d03c969fa698ad79c37280
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGES.txt CHANGED
@@ -1,3 +1,21 @@
1
+ === 4.1.1
2
+
3
+ * A read error during a single-file download is raised once, after the outstanding reads complete and the handle is closed, instead of once per in-flight read [#212]
4
+ * `abort!` now stops uploads and downloads: no new write or read is sent, the requests in flight finish and the open files are closed, so `wait` returns once their replies arrive. Previously the transfer kept running to the end, and an upload reported `:finish` twice. [#211]
5
+ * Single-file uploads keep up to `:requests` writes in flight (default 64, like OpenSSH's sftp client, previously 2) so large uploads are no longer bound by round-trip time. The file is closed once every write is acknowledged, and a failed write is raised once, after the close. [#210]
6
+ * Package only `lib`, `CHANGES.txt`, `LICENSE.txt` and `README.rdoc` in the gem, about 20% smaller. Thanks @yuri-zubov [#199]
7
+
8
+ === 4.1.0
9
+
10
+ * BREAKING: Require Ruby 2.6 or later. Bundler on older Rubies resolves to 4.0.2. [#207]
11
+ * BREAKING: `upload` and `download` no longer close an IO object passed in as the local side; `download` flushes it instead. Callers that relied on the close must close it themselves. [#202]
12
+ * Security: An `FXP_VERSION` packet after the session is open now raises `Net::SFTP::Exception` instead of switching protocol version mid-session and dropping pending requests. [#193]
13
+ * Security: A server can no longer make the client allocate without bound, or hang, by sending an ACL, extended attribute or directory listing count larger than the packet holds; such packets now raise `Net::SFTP::Exception`. [#192]
14
+ * Single-file downloads keep up to `:requests` reads in flight (default 64) instead of waiting for each 32 KB reply, so large downloads are no longer bound by round-trip time. [#204]
15
+ * `upload!` and `download!` now give new files and directories the source's permissions (masked to 0777, subject to the umask) like OpenSSH's sftp client, so executables stay executable and private files are no longer created world-readable. The new `:preserve` option also copies access and modification times and sets permissions exactly. As with OpenSSH, attributes are best effort: if they can't be read the transfer uses default permissions, and if they can't be set a warning is logged and the transfer still succeeds. [#203]
16
+ * Added `Net::SFTP::Session#extensions`, the extensions the server advertised in its `FXP_VERSION` packet (such as `posix-rename@openssh.com`). [#195]
17
+ * The `:version` option now caps the negotiated SFTP protocol version; servers that always reply with their own version (OpenSSH replies 3) used to be negotiated at that version instead of the one requested. [#194]
18
+
1
19
  === 4.0.2
2
20
 
3
21
  * Security: Recursive downloads refuse to follow an existing local symlink at a server-named path instead of writing through it. The local path passed by the caller may still be a symlink. Suggested in #162 by srosenhamer [#186]
data/README.rdoc CHANGED
@@ -35,6 +35,9 @@ In a nutshell:
35
35
  # download a file or directory from the remote host
36
36
  sftp.download!("/path/to/remote", "/path/to/local")
37
37
 
38
+ # also copy access and modification times (permissions are always copied)
39
+ sftp.download!("/path/to/remote", "/path/to/local", :preserve => true)
40
+
38
41
  # grab data off the remote host directly to a buffer
39
42
  data = sftp.download!("/path/to/remote")
40
43
 
@@ -49,12 +49,26 @@ module Net; module SFTP; module Operations
49
49
  # * <tt>:progress</tt> - either a block or an object to act as a progress
50
50
  # callback. See the discussion of "progress monitoring" below.
51
51
  # * <tt>:requests</tt> - the number of pending SFTP requests to allow at
52
- # any given time. When downloading an entire directory tree recursively,
53
- # this will default to 16. Setting this higher might improve throughput.
54
- # 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.
55
58
  # * <tt>:read_size</tt> - the maximum number of bytes to read at a time
56
59
  # from the source. Increasing this value might improve throughput. It
57
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.
58
72
  #
59
73
  # == Progress Monitoring
60
74
  #
@@ -155,6 +169,8 @@ module Net; module SFTP; module Operations
155
169
  @progress = progress || options[:progress]
156
170
  @options = options
157
171
  @active = 0
172
+ @aborted = false
173
+ @directories = []
158
174
  @properties = options[:properties] || {}
159
175
  requests
160
176
  read_size
@@ -181,9 +197,10 @@ module Net; module SFTP; module Operations
181
197
  @active > 0 || stack.any?
182
198
  end
183
199
 
184
- # Forces the transfer to stop.
200
+ # Forces the transfer to stop. Reads already sent are left to finish,
201
+ # then each open file is closed, so #wait returns once that is done.
185
202
  def abort!
186
- @active = 0
203
+ @aborted = true
187
204
  @stack.clear
188
205
  end
189
206
 
@@ -210,7 +227,8 @@ module Net; module SFTP; module Operations
210
227
 
211
228
  # A simple struct for encapsulating information about a single remote
212
229
  # file or directory that needs to be downloaded.
213
- Entry = Struct.new(:remote, :local, :directory, :size, :handle, :offset, :sink)
230
+ Entry = Struct.new(:remote, :local, :directory, :size, :handle, :offset, :sink,
231
+ :next_read, :pending, :window, :chunks, :eof, :attributes, :error)
214
232
 
215
233
  #--
216
234
  # "ruby -w" hates private attributes, so we have to do these longhand
@@ -231,10 +249,19 @@ module Net; module SFTP; module Operations
231
249
  @read_size ||= positive_option(:read_size, DEFAULT_READ_SIZE)
232
250
  end
233
251
 
234
- # The number of simultaneou SFTP requests to use to effect the download.
235
- # Defaults to 16 for recursive downloads.
252
+ # The number of simultaneous SFTP requests to use to effect the download.
253
+ # Defaults to 16 for recursive downloads and 64 for single files.
236
254
  def requests
237
- @requests ||= positive_option(:requests, recursive? ? 16 : 2)
255
+ @requests ||= positive_option(:requests, recursive? ? 16 : 64)
256
+ end
257
+
258
+ # The most reads a single file may have outstanding at once.
259
+ def read_window_limit
260
+ recursive? ? 1 : requests
261
+ end
262
+
263
+ def preserve?
264
+ options[:preserve]
238
265
  end
239
266
 
240
267
  def positive_option(name, default)
@@ -254,7 +281,12 @@ module Net; module SFTP; module Operations
254
281
  if entry.directory
255
282
  update_progress(:mkdir, entry.local)
256
283
  refuse_symlink!(entry.local) if server_named?(entry) && ::File.symlink?(entry.local)
257
- ::Dir.mkdir(entry.local) unless ::File.directory?(entry.local)
284
+ created = !::File.directory?(entry.local)
285
+ ::Dir.mkdir(entry.local, local_permissions(entry, 0777) | 0300) if created
286
+ if created || preserve?
287
+ @directories << entry
288
+ stat_directory(entry) unless entry.attributes
289
+ end
258
290
  request = sftp.opendir(entry.remote, &method(:on_opendir))
259
291
  request[:entry] = entry
260
292
  else
@@ -262,7 +294,64 @@ module Net; module SFTP; module Operations
262
294
  end
263
295
  end
264
296
 
265
- update_progress(:finish) if !active?
297
+ if !active?
298
+ set_directory_attributes
299
+ update_progress(:finish)
300
+ end
301
+ end
302
+
303
+ # Only entries listed by readdir come with attributes.
304
+ def stat_directory(entry)
305
+ @active += 1
306
+ sftp.stat(entry.remote) do |response|
307
+ @active -= 1
308
+ entry.attributes = remote_attributes(response, "stat #{entry.remote}")
309
+ process_next_entry
310
+ end
311
+ end
312
+
313
+ # Without the remote attributes the download goes ahead with default
314
+ # permissions and no times, as it would for a server that never sent any.
315
+ def remote_attributes(response, operation)
316
+ return response[:attrs] if response.ok?
317
+
318
+ debug { "#{operation} failed, using default attributes: #{response}" }
319
+ nil
320
+ end
321
+
322
+ # Deepest directories first, after their contents; setting attributes on
323
+ # a directory doesn't change its parent's modification time.
324
+ def set_directory_attributes
325
+ @directories.reverse_each do |entry|
326
+ next unless entry.attributes
327
+
328
+ permissions = local_permissions(entry, 0777)
329
+ permissions &= ~::File.umask unless preserve?
330
+ set_local_attributes(entry, permissions)
331
+ end
332
+ @directories.clear
333
+ end
334
+
335
+ # setuid, setgid and sticky bits are never applied, matching OpenSSH.
336
+ def local_permissions(entry, default)
337
+ permissions = entry.attributes && entry.attributes.permissions
338
+ permissions ? permissions & 0777 : default
339
+ end
340
+
341
+ # Like OpenSSH's sftp client, failing to set attributes only warns; the
342
+ # data has already been transferred.
343
+ def set_local_attributes(entry, permissions)
344
+ attempt_local("chmod", entry) { ::File.chmod(permissions, entry.local) } if permissions
345
+
346
+ attributes = entry.attributes
347
+ return unless preserve? && attributes && attributes.atime && attributes.mtime
348
+ attempt_local("utime", entry) { ::File.utime(attributes.atime, attributes.mtime, entry.local) }
349
+ end
350
+
351
+ def attempt_local(operation, entry)
352
+ yield
353
+ rescue SystemCallError => e
354
+ lwarn { "#{operation} #{entry.local} failed: #{e.message}" }
266
355
  end
267
356
 
268
357
  # Called when a remote directory is "opened" for reading, e.g. to
@@ -281,7 +370,7 @@ module Net; module SFTP; module Operations
281
370
  # and #process_next_entry is called.
282
371
  def on_readdir(response)
283
372
  entry = response.request[:parent]
284
- if response.eof?
373
+ if response.eof? || @aborted
285
374
  request = sftp.close(entry.handle, &method(:on_closedir))
286
375
  request[:parent] = entry
287
376
  elsif !response.ok?
@@ -290,7 +379,9 @@ module Net; module SFTP; module Operations
290
379
  response[:names].each do |item|
291
380
  next if item.name == "." || item.name == ".."
292
381
  validate_entry_name!(entry, item.name)
293
- stack << Entry.new(::File.join(entry.remote, item.name), ::File.join(entry.local, item.name), item.directory?, item.attributes.size)
382
+ child = Entry.new(::File.join(entry.remote, item.name), ::File.join(entry.local, item.name), item.directory?, item.attributes.size)
383
+ child.attributes = item.attributes
384
+ stack << child
294
385
  end
295
386
 
296
387
  # take this opportunity to enqueue more requests
@@ -316,11 +407,13 @@ module Net; module SFTP; module Operations
316
407
  entry.local != local
317
408
  end
318
409
 
410
+ # Like OpenSSH, the file is always created writable by its owner.
319
411
  def open_local_file(entry)
320
- return ::File.open(entry.local, "wb") unless server_named?(entry) && defined?(::File::NOFOLLOW)
412
+ permissions = local_permissions(entry, 0666) | 0200
413
+ return ::File.open(entry.local, "wb", permissions) unless server_named?(entry) && defined?(::File::NOFOLLOW)
321
414
 
322
415
  begin
323
- ::File.open(entry.local, "wb", :flags => ::File::NOFOLLOW)
416
+ ::File.open(entry.local, "wb", permissions, :flags => ::File::NOFOLLOW)
324
417
  rescue SystemCallError
325
418
  raise unless ::File.symlink?(entry.local)
326
419
  refuse_symlink!(entry.local)
@@ -346,55 +439,148 @@ module Net; module SFTP; module Operations
346
439
  process_next_entry
347
440
  end
348
441
 
349
- # Called when a file has been opened. This will call #download_next_chunk
442
+ # Called when a file has been opened. This will call #request_reads
350
443
  # to initiate the data transfer.
351
444
  def on_open(response)
352
445
  entry = response.request[:entry]
353
446
  raise StatusException.new(response, "open #{entry.remote}") unless response.ok?
354
447
 
355
448
  entry.handle = response[:handle]
449
+ if @aborted || entry.attributes || entry.local.respond_to?(:write)
450
+ start_download(entry)
451
+ else
452
+ request = sftp.fstat(entry.handle, &method(:on_fstat))
453
+ request[:entry] = entry
454
+ end
455
+ end
456
+
457
+ # Called when the attributes of a file opened by #open_file arrive.
458
+ def on_fstat(response)
459
+ entry = response.request[:entry]
460
+ entry.attributes = remote_attributes(response, "fstat #{entry.remote}")
461
+ start_download(entry)
462
+ end
463
+
464
+ def start_download(entry)
465
+ return close_aborted_file(entry) if @aborted
466
+
356
467
  entry.sink = entry.local.respond_to?(:write) ? entry.local : open_local_file(entry)
357
468
  entry.offset = 0
469
+ entry.next_read = 0
470
+ entry.pending = 0
471
+ entry.window = 1
472
+ entry.chunks = {}
473
+
474
+ request_reads(entry)
475
+ end
358
476
 
359
- download_next_chunk(entry)
477
+ # Keeps up to entry.window reads of #read_size bytes outstanding until
478
+ # the server reports EOF. The window starts at one and grows with each
479
+ # data reply, so small files don't cost a burst of reads past their end.
480
+ def request_reads(entry)
481
+ while !entry.eof && !@aborted && entry.pending < entry.window
482
+ read_range(entry, entry.next_read, read_size)
483
+ entry.next_read += read_size
484
+ end
360
485
  end
361
486
 
362
- # Initiates a read of the next #read_size bytes from the file.
363
- def download_next_chunk(entry)
364
- request = sftp.read(entry.handle, entry.offset, read_size, &method(:on_read))
487
+ def read_range(entry, offset, length)
488
+ entry.pending += 1
489
+ request = sftp.read(entry.handle, offset, length, &method(:on_read))
365
490
  request[:entry] = entry
366
- request[:offset] = entry.offset
491
+ request[:offset] = offset
492
+ request[:length] = length
367
493
  end
368
494
 
369
- # Called when a read from a file finishes. If the read was successful
370
- # and returned data, this will call #download_next_chunk to read the
371
- # next bit from the file. Otherwise the file will be closed.
495
+ # Called when a read from a file finishes. Replies can arrive out of
496
+ # order, so data is buffered until everything before it has been
497
+ # written. Once the server has reported EOF or a read has failed, and
498
+ # no reads are left outstanding, the file is closed.
372
499
  def on_read(response)
373
500
  entry = response.request[:entry]
501
+ entry.pending -= 1
374
502
 
375
- if response.eof?
376
- update_progress(:close, entry)
377
- entry.sink.close
378
- request = sftp.close(entry.handle, &method(:on_close))
379
- request[:entry] = entry
503
+ if entry.error
504
+ # The rest of the file is no longer wanted.
505
+ elsif response.eof?
506
+ entry.eof = true
380
507
  elsif !response.ok?
381
- raise StatusException.new(response, "read #{entry.remote}")
508
+ entry.error = StatusException.new(response, "read #{entry.remote}")
509
+ elsif response[:data].empty? || response[:data].bytesize > response.request[:length]
510
+ entry.error = Net::SFTP::Exception.new("read #{entry.remote} returned #{response[:data].bytesize} bytes for a #{response.request[:length]} byte request")
382
511
  else
383
- entry.offset += response[:data].bytesize
384
- update_progress(:get, entry, response.request[:offset], response[:data])
385
- entry.sink.write(response[:data])
386
- download_next_chunk(entry)
512
+ offset = response.request[:offset]
513
+ length = response.request[:length]
514
+ data = response[:data]
515
+
516
+ # Servers may return less than asked for without being at EOF, and
517
+ # the reads after this one have already been sent.
518
+ read_range(entry, offset + data.bytesize, length - data.bytesize) if data.bytesize < length && !@aborted
519
+
520
+ entry.chunks[offset] = data
521
+ write_buffered_chunks(entry)
522
+ entry.window += 1 if entry.window < read_window_limit
523
+ end
524
+
525
+ if entry.error
526
+ close_failed_file(entry) if entry.pending.zero?
527
+ else
528
+ request_reads(entry)
529
+ close_file(entry) if entry.pending.zero?
530
+ end
531
+ end
532
+
533
+ def write_buffered_chunks(entry)
534
+ while (data = entry.chunks.delete(entry.offset))
535
+ update_progress(:get, entry, entry.offset, data)
536
+ entry.sink.write(data)
537
+ entry.offset += data.bytesize
387
538
  end
388
539
  end
389
540
 
541
+ def close_file(entry)
542
+ if entry.chunks.any? && !@aborted
543
+ raise Net::SFTP::Exception, "read #{entry.remote} hit EOF at #{entry.offset} but the server returned data beyond it"
544
+ end
545
+
546
+ update_progress(:close, entry)
547
+ # The caller's IO stays open, but is flushed so its data is written when download! returns.
548
+ if entry.sink.equal?(local)
549
+ entry.sink.flush if entry.sink.respond_to?(:flush)
550
+ else
551
+ entry.sink.close
552
+ end
553
+ unless entry.local.respond_to?(:write)
554
+ set_local_attributes(entry, preserve? ? local_permissions(entry, nil) : nil)
555
+ end
556
+ request = sftp.close(entry.handle, &method(:on_close))
557
+ request[:entry] = entry
558
+ end
559
+
560
+ # The local file is left untouched: it may hold an earlier copy.
561
+ def close_aborted_file(entry)
562
+ update_progress(:close, entry)
563
+ request = sftp.close(entry.handle, &method(:on_close))
564
+ request[:entry] = entry
565
+ end
566
+
390
567
  # Called when a file handle is closed.
391
568
  def on_close(response)
392
569
  @active -= 1
393
570
  entry = response.request[:entry]
571
+ raise entry.error if entry.error
394
572
  raise StatusException.new(response, "close #{entry.remote}") unless response.ok?
395
573
  process_next_entry
396
574
  end
397
575
 
576
+ # Waiting for every reply before raising keeps the remote handle from
577
+ # leaking and the error from being raised again by each later reply.
578
+ def close_failed_file(entry)
579
+ entry.sink.close unless entry.sink.equal?(local)
580
+ request = sftp.close(entry.handle, &method(:on_close))
581
+ request[:entry] = entry
582
+ end
583
+
398
584
  # If a progress callback or object has been set, this will report
399
585
  # the progress to that callback or object.
400
586
  def update_progress(hook, *args)
@@ -47,8 +47,9 @@ module Net; module SFTP; module Operations
47
47
  # callback. See the discussion of "progress monitoring" below.
48
48
  # * <tt>:requests</tt> - the number of pending SFTP requests to allow at
49
49
  # any given time. When uploading an entire directory tree recursively,
50
- # this will default to 16, otherwise it will default to 2. Setting this
51
- # higher might improve throughput. Reducing it will reduce throughput.
50
+ # this will default to 16, otherwise it will default to 64, like OpenSSH's
51
+ # sftp client. Setting this higher might improve throughput. Reducing it
52
+ # will reduce throughput.
52
53
  # * <tt>:read_size</tt> - the maximum number of bytes to read at a time
53
54
  # from the source. Increasing this value might improve throughput. It
54
55
  # defaults to 32,000 bytes. Each chunk is sent as one write request, so
@@ -56,6 +57,15 @@ module Net; module SFTP; module Operations
56
57
  # connection on packets over 256 KiB).
57
58
  # * <tt>:name</tt> - the filename to report to the progress monitor when
58
59
  # an IO object is given as +local+. This defaults to "<memory>".
60
+ # * <tt>:preserve</tt> - if true, the remote files and directories get the
61
+ # access and modification times of the local ones, and their permissions
62
+ # are set even on files that already exist remotely.
63
+ #
64
+ # Like OpenSSH's sftp client, new remote files and directories are created
65
+ # with the permissions of the local ones, without setuid, setgid and sticky
66
+ # bits and subject to the server's umask. Nothing is set when +local+ is an
67
+ # IO object. If the server refuses to set the attributes after the data has
68
+ # been written, a warning is logged and the upload still succeeds.
59
69
  #
60
70
  # == Progress Monitoring
61
71
  #
@@ -154,6 +164,8 @@ module Net; module SFTP; module Operations
154
164
  @options = options
155
165
  @properties = options[:properties] || {}
156
166
  @active = 0
167
+ @aborted = false
168
+ @directories = []
157
169
 
158
170
  self.logger = sftp.logger
159
171
 
@@ -169,7 +181,7 @@ module Net; module SFTP; module Operations
169
181
 
170
182
  @active += 1
171
183
  if @options[:mkdir]
172
- sftp.mkdir(remote) do |response|
184
+ sftp.mkdir(remote, directory_attributes(local, remote)) do |response|
173
185
  @active -= 1
174
186
  raise StatusException.new(response, "mkdir `#{remote}'") unless response.ok?
175
187
  requests.times do
@@ -199,11 +211,11 @@ module Net; module SFTP; module Operations
199
211
  @active > 0 || @stack.any?
200
212
  end
201
213
 
202
- # Forces the transfer to stop.
214
+ # Forces the transfer to stop. Writes already sent are left to finish,
215
+ # then each open file is closed, so #wait returns once that is done.
203
216
  def abort!
204
- @active = 0
217
+ @aborted = true
205
218
  @stack.clear
206
- @uploads.clear
207
219
  end
208
220
 
209
221
  # Blocks until the upload has completed.
@@ -248,15 +260,19 @@ module Net; module SFTP; module Operations
248
260
  value
249
261
  end
250
262
 
263
+ def preserve?
264
+ options[:preserve]
265
+ end
266
+
251
267
  # A simple struct for recording metadata about the file currently being
252
268
  # uploaded.
253
- LiveFile = Struct.new(:local, :remote, :io, :size, :handle)
269
+ LiveFile = Struct.new(:local, :remote, :io, :size, :handle, :attributes, :pending, :error)
254
270
 
255
271
  # The default # of bytes to read from disk at a time.
256
272
  DEFAULT_READ_SIZE = 32_000
257
273
 
258
274
  # The number of readers to use when uploading a single file.
259
- SINGLE_FILE_READERS = 2
275
+ SINGLE_FILE_READERS = 64
260
276
 
261
277
  # The number of readers to use when uploading a directory.
262
278
  RECURSIVE_READERS = 16
@@ -268,7 +284,11 @@ module Net; module SFTP; module Operations
268
284
  if @uploads.any?
269
285
  write_next_chunk(@uploads.first)
270
286
  elsif !active?
271
- update_progress(:finish)
287
+ if @directories.any?
288
+ set_directory_attributes
289
+ else
290
+ update_progress(:finish)
291
+ end
272
292
  end
273
293
  return false
274
294
  elsif @stack.last.empty?
@@ -288,7 +308,7 @@ module Net; module SFTP; module Operations
288
308
 
289
309
  @active += 1
290
310
  update_progress(:mkdir, rpath)
291
- request = sftp.mkdir(rpath, &method(:on_mkdir))
311
+ request = sftp.mkdir(rpath, directory_attributes(lpath, rpath), &method(:on_mkdir))
292
312
  request[:dir] = rpath
293
313
  else
294
314
  open_file(lpath, rpath)
@@ -317,10 +337,11 @@ module Net; module SFTP; module Operations
317
337
  size = file.size
318
338
  end
319
339
 
320
- metafile = LiveFile.new(name, remote, file, size)
340
+ attributes = local.respond_to?(:read) ? {} : attributes_for(file.stat)
341
+ metafile = LiveFile.new(name, remote, file, size, nil, attributes, 0)
321
342
  update_progress(:open, metafile)
322
343
 
323
- request = sftp.open(remote, "w", &method(:on_open))
344
+ request = sftp.open(remote, "w", attributes, &method(:on_open))
324
345
  request[:file] = metafile
325
346
  end
326
347
 
@@ -353,14 +374,25 @@ module Net; module SFTP; module Operations
353
374
  end
354
375
  end
355
376
 
356
- # Called when a +write+ request finishes. Raises StatusException if the
357
- # write failed, otherwise it calls #write_next_chunk to continue the
358
- # write.
377
+ # Called when a +write+ request finishes. The first failed write stops
378
+ # the file from being read further and is recorded, to be raised once
379
+ # the file is closed. The file is closed once the whole file has been
380
+ # sent or a write has failed, and no writes are left outstanding.
359
381
  def on_write(response)
360
382
  @active -= 1
361
383
  file = response.request[:file]
362
- raise StatusException.new(response, "write #{file.remote}") unless response.ok?
363
- write_next_chunk(file)
384
+ file.pending -= 1
385
+
386
+ if !response.ok? && !file.error
387
+ file.error = StatusException.new(response, "write #{file.remote}")
388
+ stop_reading(file)
389
+ end
390
+
391
+ if file.io.nil? && file.pending.zero?
392
+ close_file(file)
393
+ elsif !file.error
394
+ write_next_chunk(file)
395
+ end
364
396
  end
365
397
 
366
398
  # Called when a +close+ request finishes. Raises a StatusException if the
@@ -369,27 +401,72 @@ module Net; module SFTP; module Operations
369
401
  def on_close(response)
370
402
  @active -= 1
371
403
  file = response.request[:file]
404
+ raise file.error if file.error
372
405
  raise StatusException.new(response, "close #{file.remote}") unless response.ok?
373
406
  process_next_entry
374
407
  end
375
408
 
409
+ # setuid, setgid and sticky bits are never sent, matching OpenSSH.
410
+ def attributes_for(stat)
411
+ attributes = { :permissions => stat.mode & 0777 }
412
+ attributes.update(:atime => stat.atime.to_i, :mtime => stat.mtime.to_i) if preserve?
413
+ attributes
414
+ end
415
+
416
+ # The directory is created writable and searchable by its owner so its
417
+ # contents can be uploaded; its real attributes are set once everything
418
+ # else is done (see #set_directory_attributes).
419
+ def directory_attributes(local, remote)
420
+ stat = ::File.stat(local)
421
+ @directories << [remote, attributes_for(stat)]
422
+ { :permissions => (stat.mode & 0777) | 0300 }
423
+ end
424
+
425
+ # Deepest directories first; setting attributes on a directory doesn't
426
+ # change its parent's modification time.
427
+ def set_directory_attributes
428
+ @directories.reverse_each do |path, attributes|
429
+ @active += 1
430
+ sftp.setstat(path, attributes) do |response|
431
+ @active -= 1
432
+ warn_unless_ok(response, "setstat #{path}")
433
+ process_next_entry
434
+ end
435
+ end
436
+ @directories.clear
437
+ end
438
+
439
+ # Called when the +fsetstat+ for :preserve finishes.
440
+ def on_fsetstat(response)
441
+ @active -= 1
442
+ file = response.request[:file]
443
+ warn_unless_ok(response, "fsetstat #{file.remote}")
444
+ end
445
+
446
+ # Like OpenSSH's sftp client, failing to set attributes only warns; the
447
+ # data has already been transferred.
448
+ def warn_unless_ok(response, operation)
449
+ lwarn { "#{operation} failed: #{response}" } unless response.ok?
450
+ end
451
+
376
452
  # Attempts to send the next chunk from the given file (where +file+ is
377
453
  # a LiveFile instance).
378
454
  def write_next_chunk(file)
379
455
  if file.io.nil?
380
456
  process_next_entry
381
457
  else
382
- @active += 1
383
458
  offset = file.io.pos
384
- data = file.io.read(read_size)
459
+ data = file.io.read(read_size) unless @aborted
385
460
  if data.nil?
386
- update_progress(:close, file)
387
- request = sftp.close(file.handle, &method(:on_close))
388
- request[:file] = file
389
- file.io.close
390
- file.io = nil
391
- @uploads.delete(file)
461
+ stop_reading(file)
462
+ if file.pending.zero?
463
+ close_file(file)
464
+ else
465
+ process_next_entry
466
+ end
392
467
  else
468
+ @active += 1
469
+ file.pending += 1
393
470
  update_progress(:put, file, offset, data)
394
471
  request = sftp.write(file.handle, offset, data, &method(:on_write))
395
472
  request[:file] = file
@@ -397,6 +474,30 @@ module Net; module SFTP; module Operations
397
474
  end
398
475
  end
399
476
 
477
+ def stop_reading(file)
478
+ # An IO passed in by the caller is theirs to close.
479
+ file.io.close unless file.io.equal?(local)
480
+ file.io = nil
481
+ @uploads.delete(file)
482
+ end
483
+
484
+ # Like OpenSSH's sftp client, CLOSE waits for every write to be
485
+ # acknowledged, so a failed write is raised once and the remote handle
486
+ # is not leaked.
487
+ def close_file(file)
488
+ @active += 1
489
+ unless file.error
490
+ update_progress(:close, file)
491
+ if preserve? && file.attributes.any?
492
+ @active += 1
493
+ request = sftp.fsetstat(file.handle, file.attributes, &method(:on_fsetstat))
494
+ request[:file] = file
495
+ end
496
+ end
497
+ request = sftp.close(file.handle, &method(:on_close))
498
+ request[:file] = file
499
+ end
500
+
400
501
  # Returns all directory entries for the given path, removing the '.'
401
502
  # and '..' relative paths.
402
503
  def entries_for(local)