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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/.github/workflows/ci.yml +2 -1
- data/CHANGES.txt +11 -0
- data/README.rdoc +3 -0
- data/Rakefile +28 -4
- data/lib/net/sftp/operations/download.rb +191 -29
- data/lib/net/sftp/operations/upload.rb +75 -7
- data/lib/net/sftp/protocol/01/attributes.rb +5 -1
- data/lib/net/sftp/protocol/01/base.rb +2 -1
- data/lib/net/sftp/protocol/04/attributes.rb +2 -1
- data/lib/net/sftp/protocol/04/base.rb +2 -1
- data/lib/net/sftp/protocol/base.rb +2 -0
- data/lib/net/sftp/protocol/bounded_read.rb +25 -0
- data/lib/net/sftp/session.rb +16 -3
- data/lib/net/sftp/version.rb +2 -2
- data/net-sftp.gemspec +1 -0
- data.tar.gz.sig +0 -0
- metadata +4 -3
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4d3d330529ea03b189dc54e18be90d35bb2bfb79374917e9e22a2fcd3d7d79ca
|
|
4
|
+
data.tar.gz: feaf13c15e37ea24c5ed45f52a842b0a8d4413cc2a27f1ee6de5a03a0975d527
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3da2ef173bd6e6ac212cf3ae73243a887ae0d136fc01f03a06b383f00a6ea19a55d2f461f5fadff10d6991dd295c6fb9c826edf6557f1d2a831fe32f2782dfdf
|
|
7
|
+
data.tar.gz: e12c6bdf4efc55e51fbf6dbe3ddb12494b8f3b090e9786bdf6deb002a6966505e9a6a63440fdb43fb23da9c20fce7b0c40d2b2e5425dd095a97dfe1648102dfb
|
checksums.yaml.gz.sig
CHANGED
|
Binary file
|
data/.github/workflows/ci.yml
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
|
53
|
-
#
|
|
54
|
-
#
|
|
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
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 #
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
request = sftp.read(entry.handle,
|
|
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] =
|
|
487
|
+
request[:offset] = offset
|
|
488
|
+
request[:length] = length
|
|
367
489
|
end
|
|
368
490
|
|
|
369
|
-
# Called when a read from a file finishes.
|
|
370
|
-
#
|
|
371
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/net/sftp/session.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
data/lib/net/sftp/version.rb
CHANGED
|
@@ -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 =
|
|
52
|
+
MINOR = 1
|
|
53
53
|
|
|
54
54
|
# The tiny component of this version of the Net::SFTP library
|
|
55
|
-
TINY =
|
|
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
|
|
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-
|
|
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: '
|
|
148
|
+
version: '2.6'
|
|
148
149
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
149
150
|
requirements:
|
|
150
151
|
- - ">="
|
metadata.gz.sig
CHANGED
|
Binary file
|