net-sftp 4.0.2 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e7dc5adb1f352eecedf9c4d5f6150b32e0e26ccdd76cb6cb242c66fbe636fd4
4
- data.tar.gz: e9b3ade5165aa0c1dbb61e64acd303794b16a31aa385472c3c513d983322fb3a
3
+ metadata.gz: 4d3d330529ea03b189dc54e18be90d35bb2bfb79374917e9e22a2fcd3d7d79ca
4
+ data.tar.gz: feaf13c15e37ea24c5ed45f52a842b0a8d4413cc2a27f1ee6de5a03a0975d527
5
5
  SHA512:
6
- metadata.gz: 8534ab3b4109bd1e5333c1e4814e5e01a855c2f84c40f60d58bfb2d6303bdc1fab20bf8102b31ef2a1b8c408e6565a7a0a26ff1dbf1b1c48445dd2f9aa49bee8
7
- data.tar.gz: b36ad98cdca42bdf48a29957105f4a104c2965280f983f937bfd64e8958b86a2354dc612e52404bba88374944bdda86016f4e06449e8fb541e26f74a0e0eeacc
6
+ metadata.gz: 3da2ef173bd6e6ac212cf3ae73243a887ae0d136fc01f03a06b383f00a6ea19a55d2f461f5fadff10d6991dd295c6fb9c826edf6557f1d2a831fe32f2782dfdf
7
+ data.tar.gz: e12c6bdf4efc55e51fbf6dbe3ddb12494b8f3b090e9786bdf6deb002a6966505e9a6a63440fdb43fb23da9c20fce7b0c40d2b2e5425dd095a97dfe1648102dfb
checksums.yaml.gz.sig CHANGED
Binary file
@@ -6,7 +6,8 @@ permissions:
6
6
  contents: read
7
7
  jobs:
8
8
  test:
9
- runs-on: ubuntu-latest
9
+ # setup-ruby has no builds of Ruby 3.1 and older for ubuntu-latest (26.04).
10
+ runs-on: ubuntu-24.04
10
11
  strategy:
11
12
  fail-fast: false
12
13
  matrix:
data/CHANGES.txt CHANGED
@@ -1,3 +1,14 @@
1
+ === 4.1.0
2
+
3
+ * BREAKING: Require Ruby 2.6 or later. Bundler on older Rubies resolves to 4.0.2. [#207]
4
+ * 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]
5
+ * 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]
6
+ * 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]
7
+ * 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]
8
+ * `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]
9
+ * Added `Net::SFTP::Session#extensions`, the extensions the server advertised in its `FXP_VERSION` packet (such as `posix-rename@openssh.com`). [#195]
10
+ * 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]
11
+
1
12
  === 4.0.2
2
13
 
3
14
  * 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
 
data/Rakefile CHANGED
@@ -68,12 +68,14 @@ def change_version(&block)
68
68
  require_relative version_file
69
69
  pre = Net::SFTP::Version::PRE
70
70
  tiny = Net::SFTP::Version::TINY
71
- result = block[pre: pre, tiny: Net::SFTP::Version::TINY]
71
+ minor = Net::SFTP::Version::MINOR
72
+ result = block[pre: pre, tiny: tiny, minor: minor]
72
73
  raise ArgumentError, "Version change logic should always return a pre" unless result.key?(:pre)
73
74
 
74
75
  new_pre = result[:pre]
75
76
  new_tiny = result[:tiny] || tiny
76
- found = { pre: false, tiny: false }
77
+ new_minor = result[:minor] || minor
78
+ found = { pre: false, tiny: false, minor: false }
77
79
  File.open("#{version_file}.new", "w") do |f|
78
80
  File.readlines(version_file).each do |line|
79
81
  match =
@@ -105,10 +107,23 @@ def change_version(&block)
105
107
  end
106
108
  end
107
109
 
110
+ if new_minor != minor
111
+ match = /^(\s+MINOR\s+=\s+)#{minor}(\s*)$/.match(line)
112
+ if match
113
+ prefix = match[1]
114
+ postfix = match[2]
115
+ new_line = "#{prefix}#{new_minor}#{postfix}"
116
+ puts "Changing:\n - #{line} + #{new_line}"
117
+ line = new_line
118
+ found[:minor] = true
119
+ end
120
+ end
121
+
108
122
  f.write(line)
109
123
  end
110
124
  raise ArgumentError, "Cound not find line: PRE = \"#{pre}\" in #{version_file}" unless found[:pre]
111
125
  raise ArgumentError, "Cound not find line: TINY = \"#{tiny}\" in #{version_file}" unless found[:tiny] || new_tiny == tiny
126
+ raise ArgumentError, "Cound not find line: MINOR = \"#{minor}\" in #{version_file}" unless found[:minor] || new_minor == minor
112
127
  end
113
128
 
114
129
  FileUtils.mv version_file, "#{version_file}.old"
@@ -119,7 +134,7 @@ end
119
134
  namespace :vbump do
120
135
  desc "Final release"
121
136
  task :final do
122
- change_version do |pre:, tiny:|
137
+ change_version do |pre:, tiny:, **|
123
138
  _ = tiny
124
139
  if pre.nil?
125
140
  { tiny: tiny + 1, pre: nil }
@@ -131,9 +146,18 @@ namespace :vbump do
131
146
  end
132
147
  end
133
148
 
149
+ desc "Minor release"
150
+ task :minor do
151
+ change_version do |pre:, minor:, **|
152
+ raise ArgumentError, "PRE is #{pre.inspect}; release it with vbump:final first" unless pre.nil?
153
+
154
+ { minor: minor + 1, tiny: 0, pre: nil }
155
+ end
156
+ end
157
+
134
158
  desc "Increment prerelease"
135
159
  task :pre, [:type] do |_t, args|
136
- change_version do |pre:, tiny:|
160
+ change_version do |pre:, tiny:, **|
137
161
  puts " PRE => #{pre.inspect}"
138
162
  match = /^([a-z]+)(\d+)/.match(pre)
139
163
  raise ArgumentError, "Unexpected pre: #{pre}" if match.nil? && args[:type].nil?
@@ -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,7 @@ module Net; module SFTP; module Operations
155
169
  @progress = progress || options[:progress]
156
170
  @options = options
157
171
  @active = 0
172
+ @directories = []
158
173
  @properties = options[:properties] || {}
159
174
  requests
160
175
  read_size
@@ -210,7 +225,8 @@ module Net; module SFTP; module Operations
210
225
 
211
226
  # A simple struct for encapsulating information about a single remote
212
227
  # file or directory that needs to be downloaded.
213
- 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)
214
230
 
215
231
  #--
216
232
  # "ruby -w" hates private attributes, so we have to do these longhand
@@ -231,10 +247,19 @@ module Net; module SFTP; module Operations
231
247
  @read_size ||= positive_option(:read_size, DEFAULT_READ_SIZE)
232
248
  end
233
249
 
234
- # The number of simultaneou SFTP requests to use to effect the download.
235
- # 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.
236
252
  def requests
237
- @requests ||= positive_option(: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]
238
263
  end
239
264
 
240
265
  def positive_option(name, default)
@@ -254,7 +279,12 @@ module Net; module SFTP; module Operations
254
279
  if entry.directory
255
280
  update_progress(:mkdir, entry.local)
256
281
  refuse_symlink!(entry.local) if server_named?(entry) && ::File.symlink?(entry.local)
257
- ::Dir.mkdir(entry.local) unless ::File.directory?(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
258
288
  request = sftp.opendir(entry.remote, &method(:on_opendir))
259
289
  request[:entry] = entry
260
290
  else
@@ -262,7 +292,64 @@ module Net; module SFTP; module Operations
262
292
  end
263
293
  end
264
294
 
265
- 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}" }
266
353
  end
267
354
 
268
355
  # Called when a remote directory is "opened" for reading, e.g. to
@@ -290,7 +377,9 @@ module Net; module SFTP; module Operations
290
377
  response[:names].each do |item|
291
378
  next if item.name == "." || item.name == ".."
292
379
  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)
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
294
383
  end
295
384
 
296
385
  # take this opportunity to enqueue more requests
@@ -316,11 +405,13 @@ module Net; module SFTP; module Operations
316
405
  entry.local != local
317
406
  end
318
407
 
408
+ # Like OpenSSH, the file is always created writable by its owner.
319
409
  def open_local_file(entry)
320
- return ::File.open(entry.local, "wb") unless server_named?(entry) && defined?(::File::NOFOLLOW)
410
+ permissions = local_permissions(entry, 0666) | 0200
411
+ return ::File.open(entry.local, "wb", permissions) unless server_named?(entry) && defined?(::File::NOFOLLOW)
321
412
 
322
413
  begin
323
- ::File.open(entry.local, "wb", :flags => ::File::NOFOLLOW)
414
+ ::File.open(entry.local, "wb", permissions, :flags => ::File::NOFOLLOW)
324
415
  rescue SystemCallError
325
416
  raise unless ::File.symlink?(entry.local)
326
417
  refuse_symlink!(entry.local)
@@ -346,45 +437,116 @@ module Net; module SFTP; module Operations
346
437
  process_next_entry
347
438
  end
348
439
 
349
- # 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
350
441
  # to initiate the data transfer.
351
442
  def on_open(response)
352
443
  entry = response.request[:entry]
353
444
  raise StatusException.new(response, "open #{entry.remote}") unless response.ok?
354
445
 
355
446
  entry.handle = response[:handle]
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)
356
463
  entry.sink = entry.local.respond_to?(:write) ? entry.local : open_local_file(entry)
357
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
358
472
 
359
- 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
360
481
  end
361
482
 
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))
483
+ def read_range(entry, offset, length)
484
+ entry.pending += 1
485
+ request = sftp.read(entry.handle, offset, length, &method(:on_read))
365
486
  request[:entry] = entry
366
- request[:offset] = entry.offset
487
+ request[:offset] = offset
488
+ request[:length] = length
367
489
  end
368
490
 
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.
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.
372
495
  def on_read(response)
373
496
  entry = response.request[:entry]
497
+ entry.pending -= 1
374
498
 
375
499
  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
500
+ entry.eof = true
380
501
  elsif !response.ok?
381
502
  raise StatusException.new(response, "read #{entry.remote}")
382
503
  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)
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
387
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
388
550
  end
389
551
 
390
552
  # Called when a file handle is closed.
@@ -56,6 +56,15 @@ module Net; module SFTP; module Operations
56
56
  # connection on packets over 256 KiB).
57
57
  # * <tt>:name</tt> - the filename to report to the progress monitor when
58
58
  # an IO object is given as +local+. This defaults to "<memory>".
59
+ # * <tt>:preserve</tt> - if true, the remote files and directories get the
60
+ # access and modification times of the local ones, and their permissions
61
+ # are set even on files that already exist remotely.
62
+ #
63
+ # Like OpenSSH's sftp client, new remote files and directories are created
64
+ # with the permissions of the local ones, without setuid, setgid and sticky
65
+ # bits and subject to the server's umask. Nothing is set when +local+ is an
66
+ # IO object. If the server refuses to set the attributes after the data has
67
+ # been written, a warning is logged and the upload still succeeds.
59
68
  #
60
69
  # == Progress Monitoring
61
70
  #
@@ -154,6 +163,7 @@ module Net; module SFTP; module Operations
154
163
  @options = options
155
164
  @properties = options[:properties] || {}
156
165
  @active = 0
166
+ @directories = []
157
167
 
158
168
  self.logger = sftp.logger
159
169
 
@@ -169,7 +179,7 @@ module Net; module SFTP; module Operations
169
179
 
170
180
  @active += 1
171
181
  if @options[:mkdir]
172
- sftp.mkdir(remote) do |response|
182
+ sftp.mkdir(remote, directory_attributes(local, remote)) do |response|
173
183
  @active -= 1
174
184
  raise StatusException.new(response, "mkdir `#{remote}'") unless response.ok?
175
185
  requests.times do
@@ -248,9 +258,13 @@ module Net; module SFTP; module Operations
248
258
  value
249
259
  end
250
260
 
261
+ def preserve?
262
+ options[:preserve]
263
+ end
264
+
251
265
  # A simple struct for recording metadata about the file currently being
252
266
  # uploaded.
253
- LiveFile = Struct.new(:local, :remote, :io, :size, :handle)
267
+ LiveFile = Struct.new(:local, :remote, :io, :size, :handle, :attributes)
254
268
 
255
269
  # The default # of bytes to read from disk at a time.
256
270
  DEFAULT_READ_SIZE = 32_000
@@ -268,7 +282,11 @@ module Net; module SFTP; module Operations
268
282
  if @uploads.any?
269
283
  write_next_chunk(@uploads.first)
270
284
  elsif !active?
271
- update_progress(:finish)
285
+ if @directories.any?
286
+ set_directory_attributes
287
+ else
288
+ update_progress(:finish)
289
+ end
272
290
  end
273
291
  return false
274
292
  elsif @stack.last.empty?
@@ -288,7 +306,7 @@ module Net; module SFTP; module Operations
288
306
 
289
307
  @active += 1
290
308
  update_progress(:mkdir, rpath)
291
- request = sftp.mkdir(rpath, &method(:on_mkdir))
309
+ request = sftp.mkdir(rpath, directory_attributes(lpath, rpath), &method(:on_mkdir))
292
310
  request[:dir] = rpath
293
311
  else
294
312
  open_file(lpath, rpath)
@@ -317,10 +335,11 @@ module Net; module SFTP; module Operations
317
335
  size = file.size
318
336
  end
319
337
 
320
- metafile = LiveFile.new(name, remote, file, size)
338
+ attributes = local.respond_to?(:read) ? {} : attributes_for(file.stat)
339
+ metafile = LiveFile.new(name, remote, file, size, nil, attributes)
321
340
  update_progress(:open, metafile)
322
341
 
323
- request = sftp.open(remote, "w", &method(:on_open))
342
+ request = sftp.open(remote, "w", attributes, &method(:on_open))
324
343
  request[:file] = metafile
325
344
  end
326
345
 
@@ -373,6 +392,49 @@ module Net; module SFTP; module Operations
373
392
  process_next_entry
374
393
  end
375
394
 
395
+ # setuid, setgid and sticky bits are never sent, matching OpenSSH.
396
+ def attributes_for(stat)
397
+ attributes = { :permissions => stat.mode & 0777 }
398
+ attributes.update(:atime => stat.atime.to_i, :mtime => stat.mtime.to_i) if preserve?
399
+ attributes
400
+ end
401
+
402
+ # The directory is created writable and searchable by its owner so its
403
+ # contents can be uploaded; its real attributes are set once everything
404
+ # else is done (see #set_directory_attributes).
405
+ def directory_attributes(local, remote)
406
+ stat = ::File.stat(local)
407
+ @directories << [remote, attributes_for(stat)]
408
+ { :permissions => (stat.mode & 0777) | 0300 }
409
+ end
410
+
411
+ # Deepest directories first; setting attributes on a directory doesn't
412
+ # change its parent's modification time.
413
+ def set_directory_attributes
414
+ @directories.reverse_each do |path, attributes|
415
+ @active += 1
416
+ sftp.setstat(path, attributes) do |response|
417
+ @active -= 1
418
+ warn_unless_ok(response, "setstat #{path}")
419
+ process_next_entry
420
+ end
421
+ end
422
+ @directories.clear
423
+ end
424
+
425
+ # Called when the +fsetstat+ for :preserve finishes.
426
+ def on_fsetstat(response)
427
+ @active -= 1
428
+ file = response.request[:file]
429
+ warn_unless_ok(response, "fsetstat #{file.remote}")
430
+ end
431
+
432
+ # Like OpenSSH's sftp client, failing to set attributes only warns; the
433
+ # data has already been transferred.
434
+ def warn_unless_ok(response, operation)
435
+ lwarn { "#{operation} failed: #{response}" } unless response.ok?
436
+ end
437
+
376
438
  # Attempts to send the next chunk from the given file (where +file+ is
377
439
  # a LiveFile instance).
378
440
  def write_next_chunk(file)
@@ -384,9 +446,15 @@ module Net; module SFTP; module Operations
384
446
  data = file.io.read(read_size)
385
447
  if data.nil?
386
448
  update_progress(:close, file)
449
+ if preserve? && file.attributes.any?
450
+ @active += 1
451
+ request = sftp.fsetstat(file.handle, file.attributes, &method(:on_fsetstat))
452
+ request[:file] = file
453
+ end
387
454
  request = sftp.close(file.handle, &method(:on_close))
388
455
  request[:file] = file
389
- file.io.close
456
+ # An IO passed in by the caller is theirs to close.
457
+ file.io.close unless file.io.equal?(local)
390
458
  file.io = nil
391
459
  @uploads.delete(file)
392
460
  else
@@ -1,4 +1,5 @@
1
1
  require 'net/ssh/buffer'
2
+ require 'net/sftp/protocol/bounded_read'
2
3
 
3
4
  module Net; module SFTP; module Protocol; module V01
4
5
 
@@ -39,6 +40,8 @@ module Net; module SFTP; module Protocol; module V01
39
40
  T_BLOCK_DEVICE = 8
40
41
  T_FIFO = 9
41
42
 
43
+ extend Net::SFTP::Protocol::BoundedRead
44
+
42
45
  class <<self
43
46
  # Returns the array of attribute meta-data that defines the structure of
44
47
  # the attributes packet as described by this version of the protocol.
@@ -106,7 +109,8 @@ module Net; module SFTP; module Protocol; module V01
106
109
  # Parse the hash of extended data from the buffer.
107
110
  def parse_extended(buffer)
108
111
  extended = Hash.new
109
- buffer.read_long.times do
112
+ # Each pair is two strings, at least 8 bytes.
113
+ read_bounded_count!(buffer, 8, "extended attribute").times do
110
114
  extended[buffer.read_string] = buffer.read_string
111
115
  end
112
116
  extended
@@ -54,7 +54,8 @@ module Net; module SFTP; module Protocol; module V01
54
54
  def parse_name_packet(packet)
55
55
  names = []
56
56
 
57
- packet.read_long.times do
57
+ # Each entry is two strings and the attribute flags, at least 12 bytes.
58
+ read_bounded_count!(packet, 12, "name").times do
58
59
  filename = packet.read_string
59
60
  longname = packet.read_string
60
61
  attrs = attribute_factory.from_buffer(packet)
@@ -69,7 +69,8 @@ module Net; module SFTP; module Protocol; module V04
69
69
  def parse_acl(buffer)
70
70
  acl_buf = Net::SSH::Buffer.new(buffer.read_string)
71
71
  acl = []
72
- acl_buf.read_long.times do
72
+ # Each entry is three longs and a string, at least 16 bytes.
73
+ read_bounded_count!(acl_buf, 16, "ACL").times do
73
74
  acl << ACL.new(acl_buf.read_long, acl_buf.read_long, acl_buf.read_long, acl_buf.read_string)
74
75
  end
75
76
  acl
@@ -28,7 +28,8 @@ module Net; module SFTP; module Protocol; module V04
28
28
  def parse_name_packet(packet)
29
29
  names = []
30
30
 
31
- packet.read_long.times do
31
+ # Each entry is a string, the attribute flags and the type, at least 9 bytes.
32
+ read_bounded_count!(packet, 9, "name").times do
32
33
  filename = packet.read_string
33
34
  attrs = attribute_factory.from_buffer(packet)
34
35
  names << name_factory.new(filename, attrs)
@@ -1,5 +1,6 @@
1
1
  require 'net/ssh/loggable'
2
2
  require 'net/sftp/constants'
3
+ require 'net/sftp/protocol/bounded_read'
3
4
 
4
5
  module Net; module SFTP; module Protocol
5
6
 
@@ -10,6 +11,7 @@ module Net; module SFTP; module Protocol
10
11
  include Net::SSH::Loggable
11
12
  include Net::SFTP::Constants
12
13
  include Net::SFTP::Constants::PacketTypes
14
+ include Net::SFTP::Protocol::BoundedRead
13
15
 
14
16
  # The SFTP session object that acts as client to this protocol instance
15
17
  attr_reader :session
@@ -0,0 +1,25 @@
1
+ require 'net/sftp/errors'
2
+
3
+ module Net; module SFTP; module Protocol
4
+
5
+ # Several SFTP structures are a 32-bit item count followed by that many
6
+ # items. Net::SSH::Buffer returns nil instead of raising when a read runs
7
+ # past the end of the data, so a loop over an unchecked count from the
8
+ # server keeps allocating long after the packet is exhausted.
9
+ module BoundedRead
10
+ # Reads an item count from +buffer+ and raises Net::SFTP::Exception if it
11
+ # is missing, or larger than the remaining bytes could hold given that
12
+ # each item takes at least +min_item_size+ bytes.
13
+ def read_bounded_count!(buffer, min_item_size, what)
14
+ count = buffer.read_long
15
+ raise Net::SFTP::Exception, "truncated packet: missing #{what} count" if count.nil?
16
+
17
+ if count > buffer.available / min_item_size
18
+ raise Net::SFTP::Exception, "server sent #{count} #{what} entries in only #{buffer.available} bytes"
19
+ end
20
+
21
+ count
22
+ end
23
+ end
24
+
25
+ end; end; end
@@ -68,6 +68,10 @@ module Net; module SFTP
68
68
  # the server has not yet responded to will be represented here.
69
69
  attr_reader :pending_requests
70
70
 
71
+ # The extensions the server advertised in its FXP_VERSION packet, as a
72
+ # hash of name => data (e.g. "posix-rename@openssh.com" => "1").
73
+ attr_reader :extensions
74
+
71
75
  # Creates a new Net::SFTP instance atop the given Net::SSH connection.
72
76
  # This will return immediately, before the SFTP connection has been properly
73
77
  # initialized. Once the connection is ready, the given block will be called.
@@ -81,6 +85,7 @@ module Net; module SFTP
81
85
  @input = Net::SSH::Buffer.new
82
86
  self.logger = session.logger
83
87
  @state = :closed
88
+ @extensions = {}
84
89
  @pending_requests = {}
85
90
 
86
91
  connect(&block)
@@ -924,19 +929,27 @@ module Net; module SFTP
924
929
  # version negotiation, instantiating the appropriate Protocol instance
925
930
  # and invoking the callback given to #connect, if any.
926
931
  def do_version(packet)
932
+ # Negotiation happens once. A later FXP_VERSION would swap the protocol
933
+ # driver and drop every pending request.
934
+ unless state == :init
935
+ raise Net::SFTP::Exception, "unexpected FXP_VERSION packet while session is #{state}"
936
+ end
937
+
927
938
  debug { "negotiating sftp protocol version, mine is #{HIGHEST_PROTOCOL_VERSION_SUPPORTED}" }
928
939
 
929
940
  server_version = packet.read_long
930
941
  debug { "server reports sftp version #{server_version}" }
931
942
 
932
- negotiated_version = [server_version, HIGHEST_PROTOCOL_VERSION_SUPPORTED].min
943
+ # Servers reply with their own version whatever we offered (OpenSSH
944
+ # always says 3), so cap at the version we offered as well.
945
+ negotiated_version = [server_version, @version, HIGHEST_PROTOCOL_VERSION_SUPPORTED].compact.min
933
946
  info { "negotiated version is #{negotiated_version}" }
934
947
 
935
- extensions = {}
948
+ @extensions = {}
936
949
  until packet.eof?
937
950
  name = packet.read_string
938
951
  data = packet.read_string
939
- extensions[name] = data
952
+ @extensions[name] = data
940
953
  end
941
954
 
942
955
  @protocol = Protocol.load(self, negotiated_version)
@@ -49,10 +49,10 @@ module Net
49
49
  MAJOR = 4
50
50
 
51
51
  # The minor component of this version of the Net::SFTP library
52
- MINOR = 0
52
+ MINOR = 1
53
53
 
54
54
  # The tiny component of this version of the Net::SFTP library
55
- TINY = 2
55
+ TINY = 0
56
56
 
57
57
  # The prerelease component of this version of the Net::SFTP library
58
58
  # nil allowed
data/net-sftp.gemspec CHANGED
@@ -16,6 +16,7 @@ Gem::Specification.new do |spec|
16
16
  spec.homepage = "https://github.com/net-ssh/net-sftp"
17
17
  spec.license = "MIT"
18
18
  spec.required_rubygems_version = Gem::Requirement.new(">= 0") if spec.respond_to? :required_rubygems_version=
19
+ spec.required_ruby_version = ">= 2.6"
19
20
 
20
21
  spec.extra_rdoc_files = [
21
22
  "LICENSE.txt",
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: net-sftp
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.2
4
+ version: 4.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jamis Buck
@@ -32,7 +32,7 @@ cert_chain:
32
32
  mMdUtmMoVbjbik3hqkY/18G03c3PoFNpL38VLKuyyROYz1DaCqOaRju3sbOoirD+
33
33
  piQTZDCWxeKuX0Iuxq02uxkYHfoGB6In0y3OOQ==
34
34
  -----END CERTIFICATE-----
35
- date: 2026-10-25 00:00:00.000000000 Z
35
+ date: 2026-10-27 00:00:00.000000000 Z
36
36
  dependencies:
37
37
  - !ruby/object:Gem::Dependency
38
38
  name: net-ssh
@@ -125,6 +125,7 @@ files:
125
125
  - lib/net/sftp/protocol/06/attributes.rb
126
126
  - lib/net/sftp/protocol/06/base.rb
127
127
  - lib/net/sftp/protocol/base.rb
128
+ - lib/net/sftp/protocol/bounded_read.rb
128
129
  - lib/net/sftp/request.rb
129
130
  - lib/net/sftp/response.rb
130
131
  - lib/net/sftp/session.rb
@@ -144,7 +145,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
144
145
  requirements:
145
146
  - - ">="
146
147
  - !ruby/object:Gem::Version
147
- version: '0'
148
+ version: '2.6'
148
149
  required_rubygems_version: !ruby/object:Gem::Requirement
149
150
  requirements:
150
151
  - - ">="
metadata.gz.sig CHANGED
Binary file